@rebasepro/server-postgresql 0.2.5 → 0.4.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.
@@ -1,7 +1,10 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
2
+ import type { EntityCollection } from "@rebasepro/types";
3
3
  /**
4
- * Auto-create auth tables if they don't exist
5
- * This runs on startup to ensure the database is ready for auth
4
+ * Auto-create auth tables if they don't exist.
5
+ *
6
+ * @param db — Drizzle database instance
7
+ * @param collection — The collection that represents auth users.
8
+ * When omitted, a default `rebase.users` table is created.
6
9
  */
7
- export declare function ensureAuthTablesExist(db: NodePgDatabase, registry?: PostgresCollectionRegistry): Promise<void>;
10
+ export declare function ensureAuthTablesExist(db: NodePgDatabase, collection?: EntityCollection): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
- import { Entity, FilterValues } from "@rebasepro/types";
3
+ import { Entity, FilterValues, LogicalCondition } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { RelationService } from "./RelationService";
6
6
  import { DrizzleClient } from "../interfaces";
@@ -104,6 +104,7 @@ export declare class EntityFetchService {
104
104
  searchString?: string;
105
105
  databaseId?: string;
106
106
  vectorSearch?: VectorSearchParams;
107
+ logical?: LogicalCondition;
107
108
  }): Promise<Entity<M>[]>;
108
109
  /**
109
110
  * Fallback path used when db.query is unavailable.
@@ -1,6 +1,6 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { FilterValues, WhereFilterOp, Relation } from "@rebasepro/types";
3
+ import { FilterValues, WhereFilterOp, Relation, LogicalCondition, FilterCondition } from "@rebasepro/types";
4
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
5
  /** Drizzle dynamic query builder — accepts innerJoin + where chaining */
6
6
  export interface DrizzleDynamicQuery {
@@ -22,6 +22,10 @@ export declare class DrizzleConditionBuilder {
22
22
  * Build filter conditions from FilterValues
23
23
  */
24
24
  static buildFilterConditions<M extends Record<string, unknown>>(filter: FilterValues<Extract<keyof M, string>>, table: PgTable<any>, collectionPath: string): SQL[];
25
+ /**
26
+ * Build logical conditions recursively from LogicalCondition or FilterCondition
27
+ */
28
+ static buildLogicalConditions(cond: LogicalCondition | FilterCondition, table: PgTable<any>, collectionPath: string): SQL | null;
25
29
  /**
26
30
  * Build a single filter condition for a specific operator and value
27
31
  */
@@ -16,7 +16,17 @@ import { Entity, EntityValues } from "../types/entities";
16
16
  *
17
17
  * @group Data
18
18
  */
19
- export type WhereFieldValue = string | number | boolean | null | [WhereFilterOpShort, any];
19
+ export type WhereFieldValue = string | number | boolean | null | [WhereFilterOpShort, any] | [WhereFilterOpShort, any][];
20
+ export type WhereValue<T> = T | T[] | null;
21
+ export interface LogicalCondition {
22
+ type: "and" | "or";
23
+ conditions: (FilterCondition | LogicalCondition)[];
24
+ }
25
+ export interface FilterCondition {
26
+ column: string;
27
+ operator: FilterOperator;
28
+ value: unknown;
29
+ }
20
30
  /** Short operator strings accepted in the tuple syntax. */
21
31
  export type WhereFilterOpShort = "==" | "!=" | ">" | ">=" | "<" | "<=" | "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "not-in" | "array-contains" | "array-contains-any" | "cs" | "csa";
22
32
  export interface FindParams {
@@ -51,6 +61,8 @@ export interface FindParams {
51
61
  * ```
52
62
  */
53
63
  where?: Record<string, WhereFieldValue>;
64
+ /** Logical grouping conditions (AND/OR) */
65
+ logical?: LogicalCondition;
54
66
  /**
55
67
  * Sort order. Format: "field:direction".
56
68
  * @example "created_at:desc", "name:asc"
@@ -82,7 +94,8 @@ export type FilterOperator = WhereFilterOpShort;
82
94
  * @group Data
83
95
  */
84
96
  export interface QueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
85
- where(column: keyof M & string, operator: FilterOperator, value: unknown): this;
97
+ where<K extends keyof M & string>(column: K, operator: FilterOperator, value: WhereValue<M[K]>): this;
98
+ where(logicalCondition: LogicalCondition): this;
86
99
  orderBy(column: keyof M & string, ascending?: "asc" | "desc"): this;
87
100
  limit(count: number): this;
88
101
  offset(count: number): this;
@@ -143,7 +156,8 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
143
156
  * Count the number of records matching the given filter.
144
157
  */
145
158
  count?(params?: FindParams): Promise<number>;
146
- where(column: keyof M & string, operator: FilterOperator, value: unknown): QueryBuilderInterface<M>;
159
+ where<K extends keyof M & string>(column: K, operator: FilterOperator, value: WhereValue<M[K]>): QueryBuilderInterface<M>;
160
+ where(logicalCondition: LogicalCondition): QueryBuilderInterface<M>;
147
161
  orderBy(column: keyof M & string, ascending?: "asc" | "desc"): QueryBuilderInterface<M>;
148
162
  limit(count: number): QueryBuilderInterface<M>;
149
163
  offset(count: number): QueryBuilderInterface<M>;
@@ -31,4 +31,6 @@ export interface EmailService {
31
31
  send(options: EmailSendOptions): Promise<void>;
32
32
  /** Returns `true` when the service has valid credentials / is ready to send. */
33
33
  isConfigured(): boolean;
34
+ /** Verify connection/credentials with the email provider. */
35
+ verifyConnection?(): Promise<boolean>;
34
36
  }
@@ -7,7 +7,7 @@ import type { EntityOverrides } from "./entity_overrides";
7
7
  import type { User } from "../users";
8
8
  import type { RebaseContext } from "../rebase_context";
9
9
  import type { Relation } from "./relations";
10
- import type { EntityCustomView, EntityDetailViewConfig } from "./entity_views";
10
+ import type { EntityCustomView, FormViewConfig } from "./entity_views";
11
11
  import type { EntityAction } from "./entity_actions";
12
12
  import type { ComponentRef } from "./component_ref";
13
13
  /**
@@ -124,10 +124,14 @@ export interface BaseEntityCollection<M extends Record<string, unknown> = Record
124
124
  */
125
125
  defaultEntityAction?: "view" | "edit";
126
126
  /**
127
- * Customization options for the read-only detail view.
128
- * Only used when `defaultEntityAction` is `"view"`.
127
+ * Replace the default entity form with a custom component.
128
+ * The Builder receives the same props as entity view tabs
129
+ * (entity, formContext, collection, etc.) and has full control over the UI.
130
+ *
131
+ * Works in both edit mode and read-only mode (when `defaultEntityAction`
132
+ * is `"view"`). In read-only mode, `formContext.readOnly` will be `true`.
129
133
  */
130
- detailView?: EntityDetailViewConfig;
134
+ formView?: FormViewConfig;
131
135
  /**
132
136
  * Prevent default actions from being displayed or executed on this collection.
133
137
  */
@@ -566,7 +570,7 @@ export type WhereFilterOp = "<" | "<=" | "==" | "!=" | ">=" | ">" | "array-conta
566
570
  *
567
571
  * @group Models
568
572
  */
569
- export type FilterValues<Key extends string> = Partial<Record<Key, [WhereFilterOp, unknown]>>;
573
+ export type FilterValues<Key extends string> = Partial<Record<Key, [WhereFilterOp, unknown] | [WhereFilterOp, unknown][]>>;
570
574
  /**
571
575
  * A pre-defined filter preset for quick access in the collection toolbar.
572
576
  * Users can select a preset to instantly apply a set of filters and
@@ -56,41 +56,32 @@ export type EntityCustomView<M extends Record<string, unknown> = Record<string,
56
56
  Builder?: ComponentRef<EntityCustomViewParams<M>>;
57
57
  position?: "start" | "end";
58
58
  };
59
- export interface EntityCustomViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
60
- collection: EntityCollection<M>;
61
- entity?: Entity<M>;
62
- modifiedValues?: EntityValues<M>;
63
- formContext: FormContext<M>;
64
- parentCollectionSlugs?: string[];
65
- parentEntityIds?: string[];
66
- }
67
59
  /**
68
- * Configuration for customizing the read-only detail view of an entity.
69
- * Only used when `defaultEntityAction` is set to `"view"` on the collection.
60
+ * Configuration to replace the default entity form with a custom component.
61
+ * The Builder receives the same props as entity view tabs (entity, formContext, etc.)
62
+ * and has full control over the UI.
63
+ *
64
+ * The form tab still appears in the tab bar but renders your Builder
65
+ * instead of the auto-generated field form.
66
+ *
70
67
  * @group Models
71
68
  */
72
- export type EntityDetailViewConfig<M extends Record<string, unknown> = Record<string, unknown>> = {
73
- /**
74
- * Custom component rendered above the property display in the detail view.
75
- */
76
- Header?: ComponentRef<EntityDetailViewParams<M>>;
69
+ export type FormViewConfig<M extends Record<string, unknown> = Record<string, unknown>> = {
77
70
  /**
78
- * Custom component rendered below the property display in the detail view.
71
+ * Custom component that replaces the default form.
79
72
  */
80
- Footer?: ComponentRef<EntityDetailViewParams<M>>;
73
+ Builder: ComponentRef<EntityCustomViewParams<M>>;
81
74
  /**
82
- * Completely replace the default detail view with a custom component.
83
- * When set, Header and Footer are ignored.
75
+ * If true, the save/delete action bar is rendered alongside the custom view.
76
+ * Defaults to true.
84
77
  */
85
- Builder?: ComponentRef<EntityDetailViewParams<M>>;
78
+ includeActions?: boolean;
86
79
  };
87
- /**
88
- * Props passed to detail view customization components (Header, Footer, Builder).
89
- * @group Models
90
- */
91
- export interface EntityDetailViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
80
+ export interface EntityCustomViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
92
81
  collection: EntityCollection<M>;
93
- entity: Entity<M>;
94
- path: string;
95
- onEditClick: () => void;
82
+ entity?: Entity<M>;
83
+ modifiedValues?: EntityValues<M>;
84
+ formContext: FormContext<M>;
85
+ parentCollectionSlugs?: string[];
86
+ parentEntityIds?: string[];
96
87
  }
@@ -104,8 +104,8 @@ export interface BaseUIConfig<CustomProps = unknown> {
104
104
  disabled?: boolean | PropertyDisabledConfig;
105
105
  widthPercentage?: number;
106
106
  customProps?: CustomProps;
107
- Field?: ComponentRef;
108
- Preview?: ComponentRef;
107
+ Field?: ComponentRef<any>;
108
+ Preview?: ComponentRef<any>;
109
109
  }
110
110
  export interface BaseProperty<CustomProps = unknown> {
111
111
  ui?: BaseUIConfig<CustomProps>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgresql",
3
3
  "type": "module",
4
- "version": "0.2.5",
4
+ "version": "0.4.0",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -68,11 +68,11 @@
68
68
  "hono": "^4.12.21",
69
69
  "pg": "^8.21.0",
70
70
  "ws": "^8.20.1",
71
- "@rebasepro/common": "0.2.5",
72
- "@rebasepro/utils": "0.2.5",
73
- "@rebasepro/server-core": "0.2.5",
74
- "@rebasepro/sdk-generator": "0.2.5",
75
- "@rebasepro/types": "0.2.5"
71
+ "@rebasepro/sdk-generator": "0.4.0",
72
+ "@rebasepro/server-core": "0.4.0",
73
+ "@rebasepro/types": "0.4.0",
74
+ "@rebasepro/common": "0.4.0",
75
+ "@rebasepro/utils": "0.4.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@types/jest": "^29.5.14",
@@ -180,31 +180,44 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
180
180
  },
181
181
 
182
182
  async initializeAuth(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined> {
183
- const authConfig = config as AuthConfig | undefined;
183
+ const authConfig = config as Record<string, unknown> | undefined;
184
184
  if (!authConfig) return undefined;
185
185
 
186
186
  const internals = driverResult.internals as PostgresDriverInternals;
187
187
  const db = internals.db;
188
188
  const registry = internals.registry;
189
189
 
190
- await ensureAuthTablesExist(db, registry);
190
+ // Resolve the auth collection from the explicit config.
191
+ // This replaces the old `registry.getTable("users")` magic string lookup.
192
+ const authCollection = authConfig.collection as EntityCollection | undefined;
193
+
194
+ // ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
195
+ await ensureAuthTablesExist(db, authCollection);
191
196
 
192
197
  let emailService: EmailService | undefined;
193
198
  if (authConfig.email) {
194
- emailService = createEmailService(authConfig.email);
199
+ emailService = createEmailService(authConfig.email as EmailConfig);
195
200
  }
196
201
 
197
- const customUsersTable = registry?.getTable("users");
202
+ // Resolve the Drizzle table for the internal UserService/AuthRepository.
203
+ // These are internal Postgres-specific services that need the Drizzle table reference.
204
+ const tableName = authCollection
205
+ ? ("table" in authCollection && typeof authCollection.table === "string"
206
+ ? authCollection.table
207
+ : authCollection.slug)
208
+ : undefined;
209
+ const usersTable = tableName
210
+ ? registry.getTable(tableName) as (PgTable & Record<string, AnyPgColumn>) | undefined
211
+ : undefined;
198
212
 
199
213
  let usersSchemaName = "rebase";
200
-
201
- if (customUsersTable) {
202
- usersSchemaName = getTableConfig(customUsersTable).schema || "public";
214
+ if (authCollection && "schema" in authCollection && typeof authCollection.schema === "string") {
215
+ usersSchemaName = authCollection.schema;
203
216
  }
204
217
 
205
218
  const authTables = createAuthSchema(usersSchemaName) as unknown as AuthSchemaTables;
206
- if (customUsersTable) {
207
- authTables.users = customUsersTable as unknown as PgTable & Record<string, AnyPgColumn>;
219
+ if (usersTable) {
220
+ authTables.users = usersTable as unknown as PgTable & Record<string, AnyPgColumn>;
208
221
  }
209
222
 
210
223
  const userService = new UserService(db, authTables);
@@ -1,44 +1,46 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
- import { getTableConfig, AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
4
- import { getColumnMeta } from "../services/entity-helpers";
5
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
6
3
  import { logger } from "@rebasepro/server-core";
4
+ import type { EntityCollection } from "@rebasepro/types";
7
5
 
8
6
 
9
7
 
10
8
  /**
11
- * Auto-create auth tables if they don't exist
12
- * This runs on startup to ensure the database is ready for auth
9
+ * Auto-create auth tables if they don't exist.
10
+ *
11
+ * @param db — Drizzle database instance
12
+ * @param collection — The collection that represents auth users.
13
+ * When omitted, a default `rebase.users` table is created.
13
14
  */
14
- export async function ensureAuthTablesExist(db: NodePgDatabase, registry?: PostgresCollectionRegistry): Promise<void> {
15
+ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: EntityCollection): Promise<void> {
15
16
  logger.info("🔍 Checking auth tables...");
16
17
 
17
18
  try {
18
- // Resolve dynamic user table name and ID type
19
- let usersTableName = '"users"';
19
+ // Resolve dynamic user table name and ID type from the collection
20
+ let usersTableName = '"rebase"."users"';
20
21
  let userIdType = "TEXT";
21
- let usersSchema = "public";
22
- if (registry) {
23
- const usersTable = registry.getTable("users") as (PgTable & Record<string, AnyPgColumn>) | undefined;
24
- if (usersTable) {
25
- const { getTableName } = await import("drizzle-orm");
26
- usersSchema = getTableConfig(usersTable).schema || "public";
27
- usersTableName = usersSchema === "public" ? `"${getTableName(usersTable)}"` : `"${usersSchema}"."${getTableName(usersTable)}"`;
28
-
29
- // Inspect users.id column to match referenced column type
30
- if (usersTable.id) {
31
- const col = usersTable.id;
32
- const meta = getColumnMeta(col);
33
- const columnType = meta.columnType;
34
- if (columnType === "PgUUID") {
35
- userIdType = "UUID";
36
- } else if (columnType === "PgSerial" || columnType === "PgInteger") {
37
- userIdType = "INTEGER";
38
- } else if (columnType === "PgBigInt" || columnType === "PgBigSerial") {
39
- userIdType = "BIGINT";
40
- }
22
+ let usersSchema = "rebase";
23
+ if (collection) {
24
+ const rawTable = ("table" in collection && typeof collection.table === "string")
25
+ ? collection.table
26
+ : collection.slug;
27
+ usersSchema = ("schema" in collection && typeof collection.schema === "string")
28
+ ? collection.schema
29
+ : "public";
30
+ usersTableName = usersSchema === "public"
31
+ ? `"${rawTable}"`
32
+ : `"${usersSchema}"."${rawTable}"`;
33
+
34
+ // Derive ID column type from collection properties
35
+ const idProp = collection.properties?.id;
36
+ if (idProp) {
37
+ const isId = ("isId" in idProp) ? (idProp as unknown as Record<string, unknown>).isId : undefined;
38
+ if (isId === "uuid") {
39
+ userIdType = "UUID";
40
+ } else if (isId === "autoincrement") {
41
+ userIdType = "INTEGER";
41
42
  }
43
+ // Otherwise keep TEXT as default
42
44
  }
43
45
  }
44
46
 
@@ -5,7 +5,7 @@ import { pathToFileURL } from "url";
5
5
  import chokidar from "chokidar";
6
6
  import { generateSchema } from "./generate-drizzle-schema-logic";
7
7
  import { EntityCollection } from "@rebasepro/types";
8
- import { defaultUsersCollection } from "@rebasepro/common";
8
+
9
9
 
10
10
  // --- Helper Functions ---
11
11
 
@@ -90,11 +90,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
90
90
  collections = [];
91
91
  }
92
92
 
93
- // Always inject defaults first; developer collections override via generic dedup
94
- // Map keyed by slug — last-write-wins, so developer collections overwrite defaults
95
- collections = Array.from(
96
- new Map([defaultUsersCollection, ...collections].map(c => [c.slug, c])).values()
97
- );
93
+
98
94
 
99
95
  // Sort collections by slug alphabetically to ensure deterministic schema generation
100
96
  collections.sort((a, b) => a.slug.localeCompare(b.slug));
@@ -1,6 +1,6 @@
1
1
  import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
3
+ import { Entity, EntityCollection, FilterValues, Relation, LogicalCondition } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -465,6 +465,7 @@ export class EntityFetchService {
465
465
  offset?: number;
466
466
  startAfter?: Record<string, unknown>;
467
467
  searchString?: string;
468
+ logical?: LogicalCondition;
468
469
  },
469
470
  collectionPath: string,
470
471
  withConfig?: Record<string, unknown>
@@ -494,6 +495,11 @@ export class EntityFetchService {
494
495
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
495
496
  }
496
497
 
498
+ if (options.logical) {
499
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
500
+ if (logicalCondition) allConditions.push(logicalCondition);
501
+ }
502
+
497
503
  // Cursor-based pagination (startAfter)
498
504
  if (options.startAfter) {
499
505
  const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
@@ -700,6 +706,7 @@ export class EntityFetchService {
700
706
  searchString?: string;
701
707
  databaseId?: string;
702
708
  vectorSearch?: VectorSearchParams;
709
+ logical?: LogicalCondition;
703
710
  } = {}
704
711
  ): Promise<Entity<M>[]> {
705
712
  const collection = getCollectionByPath(collectionPath, this.registry);
@@ -774,6 +781,11 @@ export class EntityFetchService {
774
781
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
775
782
  }
776
783
 
784
+ if (options.logical) {
785
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
786
+ if (logicalCondition) allConditions.push(logicalCondition);
787
+ }
788
+
777
789
  // Vector distance threshold filter
778
790
  if (vectorMeta?.filter) {
779
791
  allConditions.push(vectorMeta.filter);
@@ -1,6 +1,6 @@
1
1
  import { and, eq, or, sql, SQL, ilike, inArray } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
3
- import { FilterValues, WhereFilterOp, Relation, JoinStep } from "@rebasepro/types";
3
+ import { FilterValues, WhereFilterOp, Relation, JoinStep, LogicalCondition, FilterCondition } from "@rebasepro/types";
4
4
  import { getColumnName, resolveCollectionRelations } from "@rebasepro/common";
5
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
6
6
  import { ConditionBuilderStatic } from "../interfaces";
@@ -37,7 +37,6 @@ export class DrizzleConditionBuilder {
37
37
  for (const [field, filterParam] of Object.entries(filter)) {
38
38
  if (!filterParam) continue;
39
39
 
40
- const [op, value] = filterParam as [WhereFilterOp, any];
41
40
  let fieldColumn = table[field as keyof typeof table] as AnyPgColumn;
42
41
 
43
42
  if (!fieldColumn) {
@@ -53,15 +52,51 @@ export class DrizzleConditionBuilder {
53
52
  continue;
54
53
  }
55
54
 
56
- const condition = this.buildSingleFilterCondition(fieldColumn, op, value);
57
- if (condition) {
58
- conditions.push(condition);
55
+ const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0])
56
+ ? (filterParam as [WhereFilterOp, any][])
57
+ : [filterParam as [WhereFilterOp, any]];
58
+
59
+ for (const [op, value] of paramsList) {
60
+ const condition = this.buildSingleFilterCondition(fieldColumn, op, value);
61
+ if (condition) {
62
+ conditions.push(condition);
63
+ }
59
64
  }
60
65
  }
61
66
 
62
67
  return conditions;
63
68
  }
64
69
 
70
+ /**
71
+ * Build logical conditions recursively from LogicalCondition or FilterCondition
72
+ */
73
+ static buildLogicalConditions(
74
+ cond: LogicalCondition | FilterCondition,
75
+ table: PgTable<any>,
76
+ collectionPath: string
77
+ ): SQL | null {
78
+ if ("type" in cond) {
79
+ const subSQLs = cond.conditions
80
+ .map(c => this.buildLogicalConditions(c, table, collectionPath))
81
+ .filter((sql): sql is SQL => sql !== null);
82
+ if (subSQLs.length === 0) return null;
83
+ return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
84
+ } else {
85
+ let fieldColumn = table[cond.column as keyof typeof table] as AnyPgColumn;
86
+ if (!fieldColumn) {
87
+ const relationKey = `${cond.column}_id`;
88
+ if (relationKey in table) {
89
+ fieldColumn = table[relationKey as keyof typeof table] as AnyPgColumn;
90
+ }
91
+ }
92
+ if (!fieldColumn) {
93
+ console.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
94
+ return null;
95
+ }
96
+ return this.buildSingleFilterCondition(fieldColumn, cond.operator as WhereFilterOp, cond.value);
97
+ }
98
+ }
99
+
65
100
  /**
66
101
  * Build a single filter condition for a specific operator and value
67
102
  */
package/src/websocket.ts CHANGED
@@ -547,15 +547,13 @@ colors: true }));
547
547
  }
548
548
  break;
549
549
 
550
- // Route subscription messages to RealtimeService
550
+ // Route subscription messages, broadcast channels, and presence to RealtimeService
551
551
  case "subscribe_collection":
552
552
  case "subscribe_entity":
553
553
  case "unsubscribe":
554
- // Broadcast channels
555
554
  case "join_channel":
556
555
  case "leave_channel":
557
556
  case "broadcast":
558
- // Presence
559
557
  case "presence_track":
560
558
  case "presence_untrack":
561
559
  case "presence_state": {