@rebasepro/common 0.7.0 → 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.
Files changed (42) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +17 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/filter-dialect.d.ts +61 -0
  4. package/dist/data/query_builder.d.ts +4 -4
  5. package/dist/data/resolveDataSource.d.ts +7 -7
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +604 -188
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +611 -186
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/util/builders.d.ts +48 -1
  12. package/dist/util/callbacks.d.ts +6 -1
  13. package/dist/util/index.d.ts +1 -0
  14. package/dist/util/permissions.d.ts +26 -2
  15. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  16. package/dist/util/policy/index.d.ts +3 -0
  17. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  18. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  19. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  20. package/dist/util/storage.d.ts +26 -1
  21. package/package.json +13 -13
  22. package/src/collections/CollectionRegistry.ts +59 -28
  23. package/src/collections/default-collections.ts +4 -4
  24. package/src/data/buildRebaseData.ts +9 -120
  25. package/src/data/filter-dialect.ts +318 -0
  26. package/src/data/query_builder.ts +10 -10
  27. package/src/data/resolveDataSource.ts +9 -9
  28. package/src/index.ts +1 -0
  29. package/src/util/builders.ts +78 -1
  30. package/src/util/callbacks.ts +8 -1
  31. package/src/util/index.ts +1 -0
  32. package/src/util/permissions.test.ts +5 -3
  33. package/src/util/permissions.ts +85 -158
  34. package/src/util/policy/evaluatePolicy.ts +146 -0
  35. package/src/util/policy/index.ts +3 -0
  36. package/src/util/policy/policyToPostgres.ts +85 -0
  37. package/src/util/policy/securityRuleToConditions.ts +67 -0
  38. package/src/util/policy/sqlToPolicy.ts +88 -0
  39. package/src/util/references.ts +1 -1
  40. package/src/util/relations.ts +8 -9
  41. package/src/util/resolutions.ts +6 -6
  42. package/src/util/storage.ts +34 -1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * REST wire-format adapter for the unified filter system.
3
+ *
4
+ * This module is the ONLY code in the entire codebase that knows about
5
+ * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
6
+ * Everything else speaks `FilterValues` exclusively.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import {
12
+ WhereFilterOp,
13
+ FilterValues,
14
+ CANONICAL_TO_REST,
15
+ REST_TO_CANONICAL,
16
+ RestFilterOp,
17
+ toCanonicalOp,
18
+ LogicalCondition,
19
+ FilterCondition
20
+ } from "@rebasepro/types";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Value coercion (querystring → typed JS values)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Coerce a raw querystring value to its natural JS type.
28
+ * - `"true"` / `"false"` → boolean
29
+ * - `"null"` → null
30
+ * - Numeric strings → number
31
+ * - Everything else → string (unchanged)
32
+ */
33
+ function coerceValue(raw: string): unknown {
34
+ if (raw === "true") return true;
35
+ if (raw === "false") return false;
36
+ if (raw === "null") return null;
37
+ if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
38
+ return raw;
39
+ }
40
+
41
+ /**
42
+ * Serialize a JS value to its querystring representation.
43
+ */
44
+ function stringifyValue(value: unknown): string {
45
+ if (value === null) return "null";
46
+ if (typeof value === "boolean") return String(value);
47
+ return String(value);
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Serialize: FilterValues → REST querystring
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Serialize a single condition tuple to a PostgREST dot-string.
56
+ *
57
+ * @example
58
+ * serializeTuple(["==", "active"]) // "eq.active"
59
+ * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
60
+ * serializeTuple([">=", 18]) // "gte.18"
61
+ */
62
+ function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
63
+ // If it's already a string, it might be a PostgREST string (with dot)
64
+ // or a raw value (without dot). In both cases, existing tests expect
65
+ // them to be passed through or treated as simple equality if no dot.
66
+ if (typeof tuple === "string") {
67
+ if (tuple.includes(".")) {
68
+ const dotIndex = tuple.indexOf(".");
69
+ const prefix = tuple.substring(0, dotIndex);
70
+ if ((REST_TO_CANONICAL as any)[prefix]) {
71
+ return tuple;
72
+ }
73
+ }
74
+ return tuple;
75
+ }
76
+
77
+ // If it's NOT a canonical tuple [WhereFilterOp, value], treat as equality.
78
+ if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !(CANONICAL_TO_REST as any)[tuple[0]]) {
79
+ return `eq.${stringifyValue(tuple)}`;
80
+ }
81
+
82
+ const [op, value] = tuple as [WhereFilterOp, unknown];
83
+ const restOp = CANONICAL_TO_REST[op];
84
+
85
+ if (Array.isArray(value)) {
86
+ const items = value.map(stringifyValue).join(",");
87
+ return `${restOp}.(${items})`;
88
+ }
89
+
90
+ return `${restOp}.${stringifyValue(value)}`;
91
+ }
92
+
93
+ /**
94
+ * Convert `FilterValues` to a PostgREST-style querystring record.
95
+ *
96
+ * - Single conditions produce a string value.
97
+ * - Multiple conditions on the same field produce a string array (repeated params).
98
+ *
99
+ * @example
100
+ * serializeFilter({ status: ["==", "active"] })
101
+ * // → { status: "eq.active" }
102
+ *
103
+ * serializeFilter({ age: [[">=", 18], ["<", 65]] })
104
+ * // → { age: ["gte.18", "lt.65"] }
105
+ */
106
+ export function serializeFilter(
107
+ filter: FilterValues<string> | Record<string, any>
108
+ ): Record<string, string | string[]> {
109
+ const result: Record<string, string | string[]> = {};
110
+
111
+ for (const [field, condition] of Object.entries(filter)) {
112
+ if (condition === undefined) continue;
113
+
114
+ // Multiple conditions on the same field: array of tuples
115
+ // We detect this by checking if the first element is also an array.
116
+ if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
117
+ result[field] = (condition as any[]).map(serializeTuple);
118
+ } else {
119
+ // Single condition (could be a tuple, a raw value, or an already-serialized string)
120
+ result[field] = serializeTuple(condition);
121
+ }
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Deserialize: REST querystring → FilterValues
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
133
+ *
134
+ * If the string doesn't match a known operator prefix, falls back to
135
+ * `["==", originalString]` (treating the whole string as an equality value).
136
+ */
137
+ function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
138
+ const dotIndex = raw.indexOf(".");
139
+ if (dotIndex === -1) {
140
+ // No dot → equality on the raw value (coerced)
141
+ return ["==", coerceValue(raw)];
142
+ }
143
+
144
+ const prefix = raw.substring(0, dotIndex);
145
+ const rest = raw.substring(dotIndex + 1);
146
+
147
+ // Check if the prefix is a known REST operator
148
+ const canonicalOp = (REST_TO_CANONICAL as Record<string, WhereFilterOp | undefined>)[prefix];
149
+ if (!canonicalOp) {
150
+ // Not a known operator (e.g., email "user@host.com" or version "1.2.3")
151
+ // Treat the entire string as an equality value
152
+ return ["==", raw];
153
+ }
154
+
155
+ // Parse list values: "(admin,editor)" → ["admin", "editor"]
156
+ if (rest.startsWith("(") && rest.endsWith(")")) {
157
+ const items = rest.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
158
+ return [canonicalOp, items];
159
+ }
160
+
161
+ return [canonicalOp, coerceValue(rest)];
162
+ }
163
+
164
+ /**
165
+ * Convert a PostgREST-style querystring record to `FilterValues`.
166
+ *
167
+ * - String values are parsed as single conditions.
168
+ * - String arrays (repeated query params) become multiple conditions on the same field.
169
+ *
170
+ * @example
171
+ * deserializeFilter({ status: "eq.active" })
172
+ * // → { status: ["==", "active"] }
173
+ *
174
+ * deserializeFilter({ age: ["gte.18", "lt.65"] })
175
+ * // → { age: [[">=", 18], ["<", 65]] }
176
+ */
177
+ export function deserializeFilter(
178
+ query: Record<string, any>
179
+ ): FilterValues<string> {
180
+ const result: FilterValues<string> = {};
181
+
182
+ for (const [field, raw] of Object.entries(query)) {
183
+ if (raw === undefined) continue;
184
+
185
+ // If it's already a canonical tuple [op, value], keep it as is
186
+ if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
187
+ result[field] = raw as [WhereFilterOp, unknown];
188
+ continue;
189
+ }
190
+
191
+ if (Array.isArray(raw)) {
192
+ if (raw.length === 0) continue;
193
+
194
+ // Check if it's an array of canonical tuples
195
+ if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
196
+ result[field] = raw as [WhereFilterOp, unknown][];
197
+ continue;
198
+ }
199
+
200
+ if (raw.length === 1) {
201
+ result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
202
+ } else {
203
+ // If the elements are strings, they might be PostgREST dot-strings (repeated params)
204
+ if (typeof raw[0] === "string" && raw[0].includes(".")) {
205
+ result[field] = raw.map(r => typeof r === "string" ? deserializeSingle(r) : (["==", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];
206
+ } else {
207
+ // Otherwise assume it's a list of values for an implicit "in" or just multiple conditions
208
+ result[field] = ["in", raw];
209
+ }
210
+ }
211
+ } else if (typeof raw === "string") {
212
+ result[field] = deserializeSingle(raw);
213
+ } else {
214
+ result[field] = ["==", raw];
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Logical conditions: serialize / deserialize
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
227
+ *
228
+ * @example
229
+ * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
230
+ * // → "status.eq.active"
231
+ *
232
+ * serializeLogicalCondition({ type: "or", conditions: [...] })
233
+ * // → "or(status.eq.active,status.eq.pending)"
234
+ */
235
+ export function serializeLogicalCondition(
236
+ cond: LogicalCondition | FilterCondition
237
+ ): string {
238
+ if ("type" in cond) {
239
+ // LogicalCondition (and/or)
240
+ const inner = (cond.conditions ?? [])
241
+ .map(serializeLogicalCondition)
242
+ .join(",");
243
+ return `${cond.type}(${inner})`;
244
+ }
245
+
246
+ // FilterCondition
247
+ const restOp = (CANONICAL_TO_REST as any)[cond.operator] || "eq";
248
+ if (Array.isArray(cond.value)) {
249
+ const items = cond.value.map(stringifyValue).join(",");
250
+ return `${cond.column}.${restOp}.(${items})`;
251
+ }
252
+ return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
253
+ }
254
+
255
+ /**
256
+ * Parse a logical condition wire-format string back into a
257
+ * `LogicalCondition` or `FilterCondition`.
258
+ *
259
+ * @example
260
+ * deserializeLogicalCondition("status.eq.active")
261
+ * // → { column: "status", operator: "==", value: "active" }
262
+ *
263
+ * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
264
+ * // → { type: "or", conditions: [...] }
265
+ */
266
+ export function deserializeLogicalCondition(
267
+ str: string
268
+ ): LogicalCondition | FilterCondition {
269
+ // Check for logical group: "and(...)" or "or(...)"
270
+ const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
271
+ if (logicalMatch) {
272
+ const type = logicalMatch[1] as "and" | "or";
273
+ const innerStr = logicalMatch[2];
274
+
275
+ // Split on commas that are not inside parentheses
276
+ const conditions: (LogicalCondition | FilterCondition)[] = [];
277
+ let depth = 0;
278
+ let start = 0;
279
+ for (let i = 0; i < innerStr.length; i++) {
280
+ if (innerStr[i] === "(") depth++;
281
+ else if (innerStr[i] === ")") depth--;
282
+ else if (innerStr[i] === "," && depth === 0) {
283
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
284
+ start = i + 1;
285
+ }
286
+ }
287
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
288
+
289
+ return { type, conditions };
290
+ }
291
+
292
+ // FilterCondition: "column.op.value"
293
+ const firstDot = str.indexOf(".");
294
+ if (firstDot === -1) {
295
+ return { column: str, operator: "==", value: true };
296
+ }
297
+
298
+ const column = str.substring(0, firstDot);
299
+ const rest = str.substring(firstDot + 1);
300
+
301
+ const secondDot = rest.indexOf(".");
302
+ if (secondDot === -1) {
303
+ // "column.value" — treat as equality
304
+ return { column, operator: "==", value: coerceValue(rest) };
305
+ }
306
+
307
+ const opStr = rest.substring(0, secondDot);
308
+ let valueStr = rest.substring(secondDot + 1);
309
+ const operator = toCanonicalOp(opStr) ?? "==";
310
+
311
+ // Parse list values
312
+ if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
313
+ const items = valueStr.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
314
+ return { column, operator, value: items };
315
+ }
316
+
317
+ return { column, operator, value: coerceValue(valueStr) };
318
+ }
@@ -1,4 +1,4 @@
1
- import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, FilterOperator, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
1
+ import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, WhereFilterOp, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
2
2
 
3
3
  export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
4
4
  return { type: "or",
@@ -10,7 +10,7 @@ export function and(...conditions: (FilterCondition | LogicalCondition)[]): Logi
10
10
  conditions };
11
11
  }
12
12
 
13
- export function cond(column: string, operator: FilterOperator, value: unknown): FilterCondition {
13
+ export function cond(column: string, operator: WhereFilterOp, value: unknown): FilterCondition {
14
14
  return { column,
15
15
  operator,
16
16
  value };
@@ -26,9 +26,9 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
26
26
  * @example
27
27
  * client.collection('users').where('age', '>=', 18).find()
28
28
  */
29
- where<K extends keyof M & string>(column: K, operator: FilterOperator, value: WhereValue<M[K]>): this;
29
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
30
30
  where(logicalCondition: LogicalCondition): this;
31
- where(columnOrCondition: string | LogicalCondition, operator?: FilterOperator, value?: unknown): this {
31
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
32
32
  // Handle LogicalCondition signature
33
33
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
34
34
  this.params.logical = columnOrCondition as LogicalCondition;
@@ -40,18 +40,18 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
40
40
  }
41
41
 
42
42
  const column = columnOrCondition as string;
43
- const condition: [FilterOperator, unknown] = [operator!, value];
43
+ const condition: [WhereFilterOp, unknown] = [operator!, value];
44
44
  const existing = this.params.where[column];
45
45
 
46
46
  if (existing === undefined) {
47
47
  this.params.where[column] = condition;
48
48
  } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
49
- (this.params.where[column] as [FilterOperator, unknown][]).push(condition);
49
+ (this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
50
50
  } else {
51
51
  // Convert existing single tuple/value into array of tuples
52
- let firstCondition: [FilterOperator, unknown];
52
+ let firstCondition: [WhereFilterOp, unknown];
53
53
  if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
54
- firstCondition = existing as [FilterOperator, unknown];
54
+ firstCondition = existing as [WhereFilterOp, unknown];
55
55
  } else {
56
56
  firstCondition = ["==", existing];
57
57
  }
@@ -66,8 +66,8 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
66
66
  * @example
67
67
  * client.collection('users').orderBy('createdAt', 'desc').find()
68
68
  */
69
- orderBy(column: keyof M & string, ascending: "asc" | "desc" = "asc"): this {
70
- this.params.orderBy = `${column}:${ascending}`;
69
+ orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
70
+ this.params.orderBy = `${column}:${direction}`;
71
71
  return this;
72
72
  }
73
73
 
@@ -14,8 +14,8 @@ import {
14
14
  export interface DataSourceResolvable {
15
15
  /** Preferred routing key. */
16
16
  dataSource?: string;
17
- /** Legacy engine hint / fallback routing key. */
18
- driver?: string;
17
+ /** Engine type discriminant (set on variant collection types). */
18
+ engine?: string;
19
19
  /** Within-engine instance. */
20
20
  databaseId?: string;
21
21
  }
@@ -41,13 +41,13 @@ export function createDataSourceRegistry(definitions?: DataSourceDefinition[]):
41
41
  * editor's capability lookups.
42
42
  *
43
43
  * Resolution order:
44
- * 1. The routing **key** is `collection.dataSource`, else the legacy
45
- * `collection.driver`, else {@link DEFAULT_DATA_SOURCE_KEY}.
44
+ * 1. The routing **key** is `collection.dataSource`, else
45
+ * {@link DEFAULT_DATA_SOURCE_KEY}.
46
46
  * 2. If a definition is registered for that key, it provides `engine`,
47
47
  * `transport`, and `databaseId`.
48
- * 3. Otherwise values are synthesized for backward compatibility: `engine`
49
- * from the legacy `driver` (or the key, or `"postgres"`), `transport`
50
- * defaults to `"server"`, and `databaseId` from the collection.
48
+ * 3. Otherwise values are synthesized: `engine` from `collection.engine`
49
+ * (or the key, or `"postgres"`), `transport` defaults to `"server"`,
50
+ * and `databaseId` from the collection.
51
51
  *
52
52
  * `capabilities` are always derived from the resolved `engine`, so two
53
53
  * data sources sharing an engine share capabilities.
@@ -59,11 +59,11 @@ export function resolveDataSource(
59
59
  collection: DataSourceResolvable | undefined,
60
60
  registry?: DataSourceRegistry
61
61
  ): ResolvedDataSource {
62
- const key = collection?.dataSource ?? collection?.driver ?? DEFAULT_DATA_SOURCE_KEY;
62
+ const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
63
63
  const def = registry?.[key];
64
64
 
65
65
  const engine = def?.engine
66
- ?? collection?.driver
66
+ ?? collection?.engine
67
67
  ?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
68
68
 
69
69
  const transport = def?.transport ?? "server";
package/src/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./data/buildRebaseData";
4
4
  export * from "./data/buildRoutedRebaseData";
5
5
  export * from "./data/resolveDataSource";
6
6
  export * from "./data/query_builder";
7
+ export * from "./data/filter-dialect";
7
8
  export * from "./table-classification";
@@ -7,9 +7,17 @@ import {
7
7
  EntityCollection,
8
8
  EnumValueConfig,
9
9
  EnumValues,
10
+ FirebaseCollection,
11
+ FirebaseProperties,
10
12
  GeopointProperty,
13
+ InferEntityType,
11
14
  MapProperty,
12
- NumberProperty, Properties,
15
+ MongoDBCollection,
16
+ MongoProperties,
17
+ NumberProperty,
18
+ PostgresCollection,
19
+ PostgresProperties,
20
+ Properties,
13
21
  Property,
14
22
  ReferenceProperty,
15
23
  StringProperty,
@@ -32,6 +40,75 @@ export function buildCollection<
32
40
  return collection;
33
41
  }
34
42
 
43
+ // ── defineCollection ─────────────────────────────────────────────────────
44
+ // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
45
+ // to capture literal property types automatically. This gives you
46
+ // autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
47
+ // callbacks, etc. — without writing `as const` or passing manual generics.
48
+
49
+ /**
50
+ * Define a PostgreSQL-backed collection with full type inference.
51
+ *
52
+ * The `const P` generic captures literal property types from your
53
+ * `properties` object, which enables autocomplete on `titleProperty`,
54
+ * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const products = defineCollection({
59
+ * name: "Products",
60
+ * slug: "products",
61
+ * table: "products",
62
+ * properties: {
63
+ * name: { name: "Name", type: "string", validation: { required: true } },
64
+ * price: { name: "Price", type: "number" },
65
+ * },
66
+ * titleProperty: "name", // ✅ autocomplete: "name" | "price"
67
+ * sort: ["price", "asc"], // ✅ autocomplete on first element
68
+ * });
69
+ * ```
70
+ *
71
+ * @group Builder
72
+ */
73
+ export function defineCollection<
74
+ const P extends PostgresProperties,
75
+ USER extends User = User
76
+ >(
77
+ collection: Omit<PostgresCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
78
+ ): PostgresCollection<InferEntityType<P>, USER> & { properties: P };
79
+
80
+ /**
81
+ * Define a Firestore-backed collection with full type inference.
82
+ * @group Builder
83
+ */
84
+ export function defineCollection<
85
+ const P extends FirebaseProperties,
86
+ USER extends User = User
87
+ >(
88
+ collection: Omit<FirebaseCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
89
+ ): FirebaseCollection<InferEntityType<P>, USER> & { properties: P };
90
+
91
+ /**
92
+ * Define a MongoDB-backed collection with full type inference.
93
+ * @group Builder
94
+ */
95
+ export function defineCollection<
96
+ const P extends MongoProperties,
97
+ USER extends User = User
98
+ >(
99
+ collection: Omit<MongoDBCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
100
+ ): MongoDBCollection<InferEntityType<P>, USER> & { properties: P };
101
+
102
+ /**
103
+ * Implementation — delegates to the correct overload at the type level.
104
+ * At runtime this is a plain identity function.
105
+ */
106
+ export function defineCollection(
107
+ collection: EntityCollection
108
+ ): EntityCollection {
109
+ return collection;
110
+ }
111
+
35
112
  /**
36
113
  * Identity function we use to defeat the type system of Typescript and preserve
37
114
  * the property keys.
@@ -1,4 +1,11 @@
1
- import { EntityCallbacks, Properties } from "@rebasepro/types";
1
+ import { EntityCallbacks, Properties, RebaseCallContext } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Context passed to entity lifecycle callbacks.
5
+ * @group Models
6
+ */
7
+ export type EntityCallbackContext = RebaseCallContext;
8
+
2
9
 
3
10
  /**
4
11
  * Helper function to recursively check if there are any callbacks in the properties.
package/src/util/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./entities";
4
4
  export * from "./enums";
5
5
  export * from "./paths";
6
6
  export * from "./resolutions";
7
+ export * from "./policy";
7
8
  export * from "./permissions";
8
9
  export * from "./references";
9
10
  export * from "./navigation_from_path";
@@ -162,9 +162,10 @@ roles: ["author"] }
162
162
  expect(canReadCollection(collection, mockAuthController)).toBe(true);
163
163
  });
164
164
 
165
- test("11. Empty roles array [] on rule grants access to everyone (public)", () => {
165
+ test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
166
166
  const collection = createMockCollection([
167
167
  { operation: "insert",
168
+ access: "public",
168
169
  roles: [] }
169
170
  ]);
170
171
  expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
@@ -172,9 +173,10 @@ roles: [] }
172
173
  expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
173
174
  });
174
175
 
175
- test("12. Undefined roles on rule grants access to everyone (public)", () => {
176
+ test("12. Undefined roles on a public rule grants access to everyone", () => {
176
177
  const collection = createMockCollection([
177
- { operation: "insert" }
178
+ { operation: "insert",
179
+ access: "public" }
178
180
  ]);
179
181
  expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
180
182
  expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);