@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g249daa1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
- package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
- package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
- package/dist/index.es.js +765 -223
- package/dist/index.es.js.map +1 -1
- package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-tables.d.ts +2 -2
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +199 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +25 -7
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/realtimeService.d.ts +27 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/PostgresBootstrapper.ts +41 -2
- package/src/auth/ensure-tables.ts +185 -86
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +31 -3
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +165 -20
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-postgres-ddl-logic.ts +266 -15
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +558 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +148 -108
- package/src/services/PersistService.ts +14 -1
- package/src/services/RelationService.ts +2 -1
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/dataService.ts +3 -0
- package/src/services/realtimeService.ts +46 -37
- package/src/utils/drizzle-conditions.ts +223 -2
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { sql as drizzleSql, SQL } from "drizzle-orm";
|
|
2
2
|
import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AnonymousGrantRisk,
|
|
5
|
+
findAnonymousGrants,
|
|
6
|
+
REBASE_USER_ROLE,
|
|
7
|
+
revokeInternalTableAccess,
|
|
8
|
+
securityRuleToConditions
|
|
9
|
+
} from "@rebasepro/common";
|
|
10
|
+
import { REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
|
|
4
11
|
import { logger } from "@rebasepro/server";
|
|
5
12
|
|
|
6
13
|
/**
|
|
@@ -36,8 +43,14 @@ import { logger } from "@rebasepro/server";
|
|
|
36
43
|
* not an operator opt-in.
|
|
37
44
|
*/
|
|
38
45
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The restricted role every authenticated (user-context) request runs as.
|
|
48
|
+
*
|
|
49
|
+
* Re-exported, not re-declared: the same name is needed by
|
|
50
|
+
* `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
|
|
51
|
+
* fail as a silent no-op rather than an error.
|
|
52
|
+
*/
|
|
53
|
+
export { REBASE_USER_ROLE };
|
|
41
54
|
|
|
42
55
|
/** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
|
|
43
56
|
export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
|
|
@@ -69,6 +82,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
|
|
|
69
82
|
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
70
83
|
const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
71
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Warn when the connection role shares its name with an existing schema.
|
|
87
|
+
*
|
|
88
|
+
* Postgres resolves unqualified names through `search_path`, which defaults to
|
|
89
|
+
* `"$user", public` — and `$user` is the connection ROLE. When a schema of that
|
|
90
|
+
* name exists it sits ahead of `public`, so every unqualified statement
|
|
91
|
+
* silently operates on it instead:
|
|
92
|
+
*
|
|
93
|
+
* CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
|
|
94
|
+
*
|
|
95
|
+
* Nothing errors. You get a second table of the same name in the wrong schema,
|
|
96
|
+
* and reads that pin `public` cannot see it — which reads as "missing table" and
|
|
97
|
+
* sends people to re-run a push that creates a *third* copy. The bootstrapper
|
|
98
|
+
* has a whole branch dedicated to recognising the symptom after the fact.
|
|
99
|
+
*
|
|
100
|
+
* Rebase shipped straight into this: it creates a schema named `rebase` while
|
|
101
|
+
* every template named the database role `rebase` too. The scaffold uses
|
|
102
|
+
* `rebase_app` now, and every pool Rebase opens pins `search_path=public`
|
|
103
|
+
* (`pinSearchPath`), which covers the paths the framework controls. This covers
|
|
104
|
+
* the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
|
|
105
|
+
* a hand-written migration — because the hazard is a property of the two NAMES,
|
|
106
|
+
* not of any one connection.
|
|
107
|
+
*
|
|
108
|
+
* A warning rather than a boot failure: the database works, the framework's own
|
|
109
|
+
* traffic is pinned, and refusing to start over a naming choice a user may have
|
|
110
|
+
* inherited would be worse than the risk.
|
|
111
|
+
*/
|
|
112
|
+
export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
|
|
113
|
+
try {
|
|
114
|
+
const rows = await run(`
|
|
115
|
+
SELECT current_user AS role,
|
|
116
|
+
EXISTS (
|
|
117
|
+
SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
|
|
118
|
+
) AS collides
|
|
119
|
+
`);
|
|
120
|
+
if (rows[0]?.collides !== true) return;
|
|
121
|
+
const role = String(rows[0]?.role ?? "the connection role");
|
|
122
|
+
logger.warn(
|
|
123
|
+
`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
|
|
124
|
+
`names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
|
|
125
|
+
`BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
|
|
126
|
+
`path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
|
|
127
|
+
`Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
|
|
128
|
+
`hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
|
|
129
|
+
`"rebase_app".`
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
// A diagnostic must never be the reason a boot fails.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
72
136
|
export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
|
|
73
137
|
const rows = await run(`
|
|
74
138
|
SELECT current_user AS role,
|
|
@@ -180,6 +244,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
|
|
|
180
244
|
// migrations), so a migrate can never strand the user role.
|
|
181
245
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
182
246
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
247
|
+
|
|
248
|
+
// The grants above are deliberately schema-wide — a project's own
|
|
249
|
+
// collections may live in `rebase`, and future tables must be reachable
|
|
250
|
+
// or a migration strands the role. Rebase's OWN tables are the exception:
|
|
251
|
+
// refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
|
|
252
|
+
// row an end user should ever address. Taking the privilege back here
|
|
253
|
+
// covers every table that already exists; each creator revokes on the
|
|
254
|
+
// table it just made, for the boot that creates them for the first time.
|
|
255
|
+
await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
|
|
256
|
+
onError: (table, error) => logger.warn(
|
|
257
|
+
`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
|
|
258
|
+
"it stays reachable by authenticated requests: " +
|
|
259
|
+
(error instanceof Error ? error.message : String(error))
|
|
260
|
+
)
|
|
261
|
+
});
|
|
183
262
|
}
|
|
184
263
|
|
|
185
264
|
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
@@ -293,6 +372,65 @@ export function warnOnAnonymousGrants(
|
|
|
293
372
|
);
|
|
294
373
|
}
|
|
295
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
|
|
377
|
+
*
|
|
378
|
+
* The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
|
|
379
|
+
* database, so nothing is broken and no policy is wrong — which is exactly why
|
|
380
|
+
* this has to be said out loud. A silent rewrite that works forever is not a
|
|
381
|
+
* migration, it is a second supported spelling nobody wrote down, and the next
|
|
382
|
+
* person to read those rules will copy the old one.
|
|
383
|
+
*
|
|
384
|
+
* Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
|
|
385
|
+
* `policy.rolesOverlap(...)`) compile from the model and were never affected.
|
|
386
|
+
*/
|
|
387
|
+
export function warnOnLegacyRlsFunctions(
|
|
388
|
+
collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
|
|
389
|
+
): void {
|
|
390
|
+
const sites: string[] = [];
|
|
391
|
+
|
|
392
|
+
for (const collection of collections) {
|
|
393
|
+
for (const rule of collection.securityRules ?? []) {
|
|
394
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
395
|
+
const carriesLegacy = [usingExpr, withCheckExpr]
|
|
396
|
+
.filter((e): e is PolicyExpression => e !== null)
|
|
397
|
+
.some(containsLegacyRlsCall);
|
|
398
|
+
if (!carriesLegacy) continue;
|
|
399
|
+
|
|
400
|
+
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
401
|
+
if (!sites.includes(site)) sites.push(site);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (sites.length === 0) return;
|
|
406
|
+
|
|
407
|
+
logger.warn(
|
|
408
|
+
`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
|
|
409
|
+
`They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
|
|
410
|
+
`and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
|
|
411
|
+
`and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
|
|
412
|
+
`which never had to be spelled by hand:\n\n` +
|
|
413
|
+
sites.map(s => ` • ${s}`).join("\n") + "\n"
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
|
|
418
|
+
function containsLegacyRlsCall(expr: PolicyExpression): boolean {
|
|
419
|
+
switch (expr.kind) {
|
|
420
|
+
case "raw":
|
|
421
|
+
return usesLegacyRlsFunctions(expr.sql);
|
|
422
|
+
case "and":
|
|
423
|
+
case "or":
|
|
424
|
+
return expr.operands.some(containsLegacyRlsCall);
|
|
425
|
+
case "not":
|
|
426
|
+
return containsLegacyRlsCall(expr.operand);
|
|
427
|
+
case "existsIn":
|
|
428
|
+
return containsLegacyRlsCall(expr.where);
|
|
429
|
+
default:
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
296
434
|
/**
|
|
297
435
|
* Reject `pgRoles` that this server can never satisfy.
|
|
298
436
|
*
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { sql } from "drizzle-orm";
|
|
11
11
|
import { BranchInfo } from "@rebasepro/types";
|
|
12
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
12
13
|
import { DrizzleClient } from "../interfaces";
|
|
13
14
|
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
15
|
import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
|
|
@@ -124,6 +125,10 @@ export class BranchService {
|
|
|
124
125
|
metadata JSONB DEFAULT '{}'
|
|
125
126
|
);
|
|
126
127
|
`));
|
|
128
|
+
|
|
129
|
+
// Not a collection, so no RLS — and it names every branch database on
|
|
130
|
+
// this server. The driver's schema-wide grant reaches it, so revoke.
|
|
131
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
/**
|
|
@@ -22,6 +22,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
|
|
|
22
22
|
import { DrizzleClient } from "../interfaces";
|
|
23
23
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
24
24
|
import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
25
|
+
import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
|
|
25
26
|
import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
|
|
26
27
|
import { ApiError, logger } from "@rebasepro/server";
|
|
27
28
|
import { reachedDatabase } from "../utils/pg-error-utils";
|
|
@@ -119,6 +120,31 @@ export class FetchService {
|
|
|
119
120
|
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
120
121
|
* caller that hands over no collection to resolve against.
|
|
121
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* The ORDER BY target, which may be relevance rather than a column.
|
|
125
|
+
*
|
|
126
|
+
* `_score` is only meaningful for a collection that declared a `search`
|
|
127
|
+
* block *and* for a request that carried a search string — ranking rows
|
|
128
|
+
* against no query ranks them all at zero. Outside those two conditions it
|
|
129
|
+
* is an unknown field and gets the same 400 as any other typo, which is the
|
|
130
|
+
* behaviour that matters: a sort that is silently dropped returns 200 with
|
|
131
|
+
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
132
|
+
*/
|
|
133
|
+
static readonly SCORE_FIELD = "_score";
|
|
134
|
+
|
|
135
|
+
private resolveOrderTarget(
|
|
136
|
+
table: PgTable<any>,
|
|
137
|
+
orderBy: string,
|
|
138
|
+
collection?: CollectionConfig,
|
|
139
|
+
searchString?: string
|
|
140
|
+
): AnyPgColumn | SQL | undefined {
|
|
141
|
+
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
142
|
+
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
143
|
+
if (rank) return rank;
|
|
144
|
+
}
|
|
145
|
+
return this.resolveOrderByField(table, orderBy, collection);
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
private resolveOrderByField(
|
|
123
149
|
table: PgTable<any>,
|
|
124
150
|
orderBy: string,
|
|
@@ -407,6 +433,15 @@ export class FetchService {
|
|
|
407
433
|
): Record<string, unknown> {
|
|
408
434
|
const queryOpts: Record<string, unknown> = {};
|
|
409
435
|
|
|
436
|
+
// Same exclusion the `db.select` fallback applies, in the shape the
|
|
437
|
+
// relational query builder takes. Both paths serve the same request, so
|
|
438
|
+
// a row must not carry the search column down one and not the other.
|
|
439
|
+
const hidden = hiddenColumnsOption(
|
|
440
|
+
getTableColumns(table),
|
|
441
|
+
this.registry.getCollectionByPath(collectionPath) ?? undefined
|
|
442
|
+
);
|
|
443
|
+
if (hidden) queryOpts.columns = hidden;
|
|
444
|
+
|
|
410
445
|
if (withConfig) queryOpts.with = withConfig;
|
|
411
446
|
|
|
412
447
|
// Build where conditions
|
|
@@ -417,7 +452,7 @@ export class FetchService {
|
|
|
417
452
|
if (options.searchString) {
|
|
418
453
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
419
454
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
420
|
-
options.searchString, collection.properties, table
|
|
455
|
+
options.searchString, collection.properties, table, collection
|
|
421
456
|
);
|
|
422
457
|
if (searchConditions.length === 0) {
|
|
423
458
|
// Return options that will produce empty results
|
|
@@ -451,7 +486,7 @@ export class FetchService {
|
|
|
451
486
|
const orderExpressions: unknown[] = [];
|
|
452
487
|
if (options.orderBy) {
|
|
453
488
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
454
|
-
const orderByField = this.
|
|
489
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
455
490
|
if (orderByField) {
|
|
456
491
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
457
492
|
}
|
|
@@ -485,6 +520,21 @@ export class FetchService {
|
|
|
485
520
|
const cursor = options.startAfter;
|
|
486
521
|
|
|
487
522
|
if (options.orderBy) {
|
|
523
|
+
// Relevance is computed per query, not stored, so there is no value
|
|
524
|
+
// on the cursor row to compare a later page against — and two
|
|
525
|
+
// requests with different search strings would produce scores that
|
|
526
|
+
// are not on the same scale at all. Refusing is the only honest
|
|
527
|
+
// answer: a dropped cursor condition silently repeats and skips
|
|
528
|
+
// rows, which is precisely what paging exists to prevent.
|
|
529
|
+
if (options.orderBy === FetchService.SCORE_FIELD) {
|
|
530
|
+
throw ApiError.badRequest(
|
|
531
|
+
"Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
|
|
532
|
+
"Relevance is computed per query rather than stored, so it cannot key a cursor. " +
|
|
533
|
+
"Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
|
|
534
|
+
"SCORE_CURSOR_UNSUPPORTED",
|
|
535
|
+
{ field: FetchService.SCORE_FIELD }
|
|
536
|
+
);
|
|
537
|
+
}
|
|
488
538
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
489
539
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
490
540
|
if (orderByField) {
|
|
@@ -607,9 +657,12 @@ idColumn };
|
|
|
607
657
|
try {
|
|
608
658
|
const withConfig = this.buildWithConfig(collection);
|
|
609
659
|
|
|
660
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
661
|
+
|
|
610
662
|
const row = await qb.findFirst({
|
|
611
663
|
where: eq(idField, parsedId),
|
|
612
|
-
with: withConfig
|
|
664
|
+
with: withConfig,
|
|
665
|
+
...(hidden ? { columns: hidden } : {})
|
|
613
666
|
} as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
614
667
|
|
|
615
668
|
if (!row) return undefined;
|
|
@@ -631,8 +684,9 @@ idColumn };
|
|
|
631
684
|
}
|
|
632
685
|
|
|
633
686
|
// Fallback: db.select + N+1 relation loading
|
|
687
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
634
688
|
const result = await this.db
|
|
635
|
-
.select()
|
|
689
|
+
.select(visibleOne as never)
|
|
636
690
|
.from(table)
|
|
637
691
|
.where(eq(idField, parsedId))
|
|
638
692
|
.limit(1);
|
|
@@ -700,6 +754,7 @@ idColumn };
|
|
|
700
754
|
offset?: number;
|
|
701
755
|
startAfter?: Record<string, unknown>;
|
|
702
756
|
searchString?: string;
|
|
757
|
+
searchExplain?: boolean;
|
|
703
758
|
databaseId?: string;
|
|
704
759
|
vectorSearch?: VectorSearchParams;
|
|
705
760
|
logical?: LogicalCondition;
|
|
@@ -763,17 +818,41 @@ idColumn };
|
|
|
763
818
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
764
819
|
}
|
|
765
820
|
|
|
821
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
822
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
823
|
+
// SQL therefore unchanged — for any table without one.
|
|
824
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
825
|
+
|
|
826
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
827
|
+
// a vector search. Present only when the collection opted in and the
|
|
828
|
+
// request carried a search string, so a caller can order by it, show
|
|
829
|
+
// it, or blend it with a score of their own.
|
|
830
|
+
const rankSelect = options.searchString
|
|
831
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
832
|
+
: undefined;
|
|
833
|
+
|
|
834
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
835
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
836
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
837
|
+
: undefined;
|
|
838
|
+
|
|
766
839
|
let query = vectorMeta
|
|
767
|
-
? this.db.select({ table_row: table,
|
|
840
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
768
841
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
769
|
-
:
|
|
842
|
+
: rankSelect
|
|
843
|
+
? this.db.select({
|
|
844
|
+
table_row: (visible ?? table) as never,
|
|
845
|
+
_score: rankSelect,
|
|
846
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
847
|
+
}).from(table).$dynamic()
|
|
848
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
770
849
|
const allConditions: SQL[] = [];
|
|
771
850
|
|
|
772
851
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
773
852
|
|
|
774
853
|
if (options.searchString) {
|
|
775
854
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
776
|
-
options.searchString, collection.properties, table
|
|
855
|
+
options.searchString, collection.properties, table, collection
|
|
777
856
|
);
|
|
778
857
|
if (searchConditions.length === 0) return [];
|
|
779
858
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -804,7 +883,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
804
883
|
if (vectorMeta) {
|
|
805
884
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
806
885
|
} else if (options.orderBy) {
|
|
807
|
-
const orderByField = this.
|
|
886
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
808
887
|
if (orderByField) {
|
|
809
888
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
810
889
|
}
|
|
@@ -838,7 +917,15 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
838
917
|
...r.table_row,
|
|
839
918
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
840
919
|
}))
|
|
841
|
-
|
|
920
|
+
// Same nested shape, unwrapped the same way, when a relevance
|
|
921
|
+
// score was selected instead.
|
|
922
|
+
: rankSelect
|
|
923
|
+
? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
924
|
+
...r.table_row,
|
|
925
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
926
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
927
|
+
}))
|
|
928
|
+
: rawResults as Record<string, unknown>[];
|
|
842
929
|
|
|
843
930
|
return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
844
931
|
}
|
|
@@ -954,6 +1041,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
954
1041
|
collectionPath: string,
|
|
955
1042
|
options: {
|
|
956
1043
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1044
|
+
/**
|
|
1045
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1046
|
+
*
|
|
1047
|
+
* `fetchRowsWithConditions` below has always applied this; it was
|
|
1048
|
+
* simply absent from this signature, so the only callers that could
|
|
1049
|
+
* pass one were the ones that went around this method. Realtime
|
|
1050
|
+
* came through here, which is why a subscription filtered by a
|
|
1051
|
+
* logical group was pushed every row in the table.
|
|
1052
|
+
*/
|
|
1053
|
+
logical?: LogicalCondition;
|
|
957
1054
|
orderBy?: string;
|
|
958
1055
|
order?: "desc" | "asc";
|
|
959
1056
|
limit?: number;
|
|
@@ -988,6 +1085,8 @@ relatedTo: hop });
|
|
|
988
1085
|
order?: "desc" | "asc";
|
|
989
1086
|
limit?: number;
|
|
990
1087
|
databaseId?: string;
|
|
1088
|
+
/** Ask each row which declared search field matched. */
|
|
1089
|
+
searchExplain?: boolean;
|
|
991
1090
|
} = {}
|
|
992
1091
|
): Promise<Record<string, unknown>[]> {
|
|
993
1092
|
return this.fetchRowsWithConditions<M>(collectionPath, {
|
|
@@ -1024,7 +1123,7 @@ relatedTo: hop });
|
|
|
1024
1123
|
|
|
1025
1124
|
if (options.searchString) {
|
|
1026
1125
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1027
|
-
options.searchString, collection.properties, table
|
|
1126
|
+
options.searchString, collection.properties, table, collection
|
|
1028
1127
|
);
|
|
1029
1128
|
if (searchConditions.length === 0) return 0;
|
|
1030
1129
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1298,8 +1397,9 @@ relatedTo: hop }, include
|
|
|
1298
1397
|
}
|
|
1299
1398
|
|
|
1300
1399
|
// Fallback: db.select + N+1 relation loading
|
|
1400
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
1301
1401
|
const result = await this.db
|
|
1302
|
-
.select()
|
|
1402
|
+
.select(visibleOne as never)
|
|
1303
1403
|
.from(table)
|
|
1304
1404
|
.where(eq(idField, parsedId))
|
|
1305
1405
|
.limit(1);
|
|
@@ -1359,6 +1459,7 @@ relatedTo: hop }, include
|
|
|
1359
1459
|
offset?: number;
|
|
1360
1460
|
startAfter?: Record<string, unknown>;
|
|
1361
1461
|
searchString?: string;
|
|
1462
|
+
searchExplain?: boolean;
|
|
1362
1463
|
vectorSearch?: VectorSearchParams;
|
|
1363
1464
|
relatedTo?: NestedPathHop;
|
|
1364
1465
|
} = {}
|
|
@@ -1374,17 +1475,41 @@ relatedTo: hop }, include
|
|
|
1374
1475
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
1375
1476
|
}
|
|
1376
1477
|
|
|
1478
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
1479
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
1480
|
+
// SQL therefore unchanged — for any table without one.
|
|
1481
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
1482
|
+
|
|
1483
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
1484
|
+
// a vector search. Present only when the collection opted in and the
|
|
1485
|
+
// request carried a search string, so a caller can order by it, show
|
|
1486
|
+
// it, or blend it with a score of their own.
|
|
1487
|
+
const rankSelect = options.searchString
|
|
1488
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
1489
|
+
: undefined;
|
|
1490
|
+
|
|
1491
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
1492
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
1493
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
1494
|
+
: undefined;
|
|
1495
|
+
|
|
1377
1496
|
let query = vectorMeta
|
|
1378
|
-
? this.db.select({ table_row: table,
|
|
1497
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
1379
1498
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
1380
|
-
:
|
|
1499
|
+
: rankSelect
|
|
1500
|
+
? this.db.select({
|
|
1501
|
+
table_row: (visible ?? table) as never,
|
|
1502
|
+
_score: rankSelect,
|
|
1503
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
1504
|
+
}).from(table).$dynamic()
|
|
1505
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
1381
1506
|
const allConditions: SQL[] = [];
|
|
1382
1507
|
|
|
1383
1508
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
1384
1509
|
|
|
1385
1510
|
if (options.searchString) {
|
|
1386
1511
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1387
|
-
options.searchString, collection.properties, table
|
|
1512
|
+
options.searchString, collection.properties, table, collection
|
|
1388
1513
|
);
|
|
1389
1514
|
if (searchConditions.length === 0) return [];
|
|
1390
1515
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1408,7 +1533,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1408
1533
|
if (vectorMeta) {
|
|
1409
1534
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
1410
1535
|
} else if (options.orderBy) {
|
|
1411
|
-
const orderByField = this.
|
|
1536
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
1412
1537
|
if (orderByField) {
|
|
1413
1538
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1414
1539
|
}
|
|
@@ -1433,6 +1558,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1433
1558
|
}));
|
|
1434
1559
|
}
|
|
1435
1560
|
|
|
1561
|
+
if (rankSelect) {
|
|
1562
|
+
return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
1563
|
+
...r.table_row,
|
|
1564
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
1565
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
1566
|
+
}));
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1436
1569
|
return rawResults as Record<string, unknown>[];
|
|
1437
1570
|
}
|
|
1438
1571
|
|
|
@@ -1454,99 +1587,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1454
1587
|
return !!this.getQueryBuilder(tableName);
|
|
1455
1588
|
}
|
|
1456
1589
|
|
|
1457
|
-
/**
|
|
1458
|
-
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
1459
|
-
* for efficient JOIN-based relation loading.
|
|
1460
|
-
* Returns null if the API is not available or the query fails.
|
|
1461
|
-
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1462
|
-
*/
|
|
1463
|
-
private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
|
|
1464
|
-
collectionPath: string,
|
|
1465
|
-
collection: CollectionConfig,
|
|
1466
|
-
options: {
|
|
1467
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1468
|
-
orderBy?: string;
|
|
1469
|
-
order?: "desc" | "asc";
|
|
1470
|
-
limit?: number;
|
|
1471
|
-
},
|
|
1472
|
-
include: string[],
|
|
1473
|
-
idInfo: { fieldName: string; type: "string" | "number" },
|
|
1474
|
-
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1475
|
-
): Promise<Record<string, unknown>[] | null> {
|
|
1476
|
-
try {
|
|
1477
|
-
|
|
1478
|
-
const table = getTableForCollection(collection, this.registry);
|
|
1479
|
-
const tableName = getTableName(table);
|
|
1480
|
-
const queryTarget = this.getQueryBuilder(tableName);
|
|
1481
|
-
|
|
1482
|
-
if (!queryTarget?.findMany) return null;
|
|
1483
|
-
|
|
1484
|
-
// Build the `with` config from include array
|
|
1485
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1486
|
-
const withConfig: Record<string, boolean> = {};
|
|
1487
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1488
|
-
if (include[0] === "*" || include.includes(key)) {
|
|
1489
|
-
// Use the Drizzle relation name (from the schema)
|
|
1490
|
-
const drizzleRelName = relation.relationName || key;
|
|
1491
|
-
withConfig[drizzleRelName] = true;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
// Build query options
|
|
1496
|
-
const queryOpts: Record<string, unknown> = { with: withConfig };
|
|
1497
|
-
if (options.limit) queryOpts.limit = options.limit;
|
|
1498
|
-
|
|
1499
|
-
// Build where clause
|
|
1500
|
-
if (options.filter) {
|
|
1501
|
-
const filterConditions = this.buildFilterConditions(
|
|
1502
|
-
options.filter, table, collectionPath
|
|
1503
|
-
);
|
|
1504
|
-
if (filterConditions.length > 0) {
|
|
1505
|
-
queryOpts.where = and(...filterConditions);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Build orderBy
|
|
1510
|
-
if (options.orderBy) {
|
|
1511
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1512
|
-
if (orderByField) {
|
|
1513
|
-
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1519
|
-
|
|
1520
|
-
// Inline the nested Drizzle results, columns only — no synthesized id.
|
|
1521
|
-
return results.map((row: Record<string, unknown>) => {
|
|
1522
|
-
const flat: Record<string, unknown> = {};
|
|
1523
|
-
for (const [k, v] of Object.entries(row)) {
|
|
1524
|
-
if (Array.isArray(v)) {
|
|
1525
|
-
// Many relation — inline each nested row
|
|
1526
|
-
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1527
|
-
// Junction table rows may have the target nested, unwrap those
|
|
1528
|
-
const keys = Object.keys(item);
|
|
1529
|
-
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1530
|
-
if (nestedObj && keys.length <= 3) {
|
|
1531
|
-
return { ...(item[nestedObj] as Record<string, unknown>) };
|
|
1532
|
-
}
|
|
1533
|
-
return { ...item };
|
|
1534
|
-
});
|
|
1535
|
-
} else if (typeof v === "object" && v !== null) {
|
|
1536
|
-
// One-to-one relation — inline the target's columns
|
|
1537
|
-
flat[k] = { ...(v as Record<string, unknown>) };
|
|
1538
|
-
} else {
|
|
1539
|
-
flat[k] = v;
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
return flat;
|
|
1543
|
-
});
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
1546
|
-
return null;
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
1590
|
/**
|
|
1551
1591
|
* Fallback path used when db.query is unavailable.
|
|
1552
1592
|
* The primary path uses db.query.findMany with `with` config, which
|
|
@@ -477,7 +477,20 @@ export class PersistService {
|
|
|
477
477
|
const pgError = extractPgError(error);
|
|
478
478
|
|
|
479
479
|
if (pgError) {
|
|
480
|
-
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
480
|
+
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
481
|
+
// This is the only layer that holds the SQLSTATE, so it is the only
|
|
482
|
+
// one that can say whose fault a failure was. Returning a bare
|
|
483
|
+
// `Error` threw that away, and the REST layer compensated by calling
|
|
484
|
+
// *every* unclassified error a 400 — so an unreachable database was
|
|
485
|
+
// reported to callers as a bad request. Classes 22 (data exception)
|
|
486
|
+
// and 23 (integrity constraint violation) are the caller's data;
|
|
487
|
+
// everything else — a dropped connection, a missing column, a
|
|
488
|
+
// permission problem — is ours, and stays a 500.
|
|
489
|
+
if (/^2[23]/.test(code)) {
|
|
490
|
+
return code === "23505"
|
|
491
|
+
? ApiError.conflict(message, `PG_${code}`)
|
|
492
|
+
: ApiError.badRequest(message, `PG_${code}`);
|
|
493
|
+
}
|
|
481
494
|
return new Error(message);
|
|
482
495
|
}
|
|
483
496
|
|
|
@@ -423,7 +423,8 @@ export class RelationService {
|
|
|
423
423
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
424
424
|
options.searchString,
|
|
425
425
|
targetCollection.properties,
|
|
426
|
-
targetTable
|
|
426
|
+
targetTable,
|
|
427
|
+
targetCollection
|
|
427
428
|
);
|
|
428
429
|
|
|
429
430
|
if (searchConditions.length === 0) {
|
|
@@ -34,6 +34,7 @@ import { sql } from "drizzle-orm";
|
|
|
34
34
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
35
35
|
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
36
36
|
import { logger } from "@rebasepro/server";
|
|
37
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
37
38
|
|
|
38
39
|
/** How many messages a replay returns when the caller does not say. */
|
|
39
40
|
const DEFAULT_REPLAY_LIMIT = 200;
|
|
@@ -198,6 +199,13 @@ export class ChannelHistoryStore {
|
|
|
198
199
|
)
|
|
199
200
|
`);
|
|
200
201
|
|
|
202
|
+
// Retained broadcasts for every channel in one table, with no RLS: who
|
|
203
|
+
// may replay a channel is decided by the channel rules the server
|
|
204
|
+
// evaluates before it reads. The driver's schema-wide grant reaches
|
|
205
|
+
// these (created here, after it ran), so take the privilege back.
|
|
206
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
|
|
207
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
|
|
208
|
+
|
|
201
209
|
this.tablesReady = true;
|
|
202
210
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
203
211
|
}
|