@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Canonical SQL bootstrap for the RLS helper functions.
3
+ *
4
+ * Generated RLS policies reference `rebase.uid()` / `rebase.roles()` /
5
+ * `rebase.jwt()`, so any SQL stream that can contain policies must be
6
+ * self-contained: it has to (re)create these helpers first. This matters for
7
+ * the migration directory in particular — Atlas replays migrations against a
8
+ * clean dev database where no out-of-band bootstrap has ever run, so a
9
+ * migration carrying policies without this preamble fails with
10
+ * "function rebase.uid() does not exist".
11
+ *
12
+ * Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every
13
+ * policies block and re-applied freely. The runtime boot path
14
+ * (`auth/ensure-tables.ts`) creates the same functions under an advisory lock
15
+ * for HMR-safety; keep the definitions in sync.
16
+ *
17
+ * ## Creating the `rebase` schema here is now safe, and required
18
+ *
19
+ * It deliberately did not, once. These functions lived in a schema called
20
+ * `auth`, and the note here read: creating `rebase` would leak it into Atlas's
21
+ * replayed migration state, and — absent from the desired `schema.sql` — Atlas
22
+ * would then plan `DROP SCHEMA "rebase" CASCADE`, taking the auth tables with
23
+ * it. That reasoning still holds; what changed is the second half of it. The
24
+ * DDL generator now emits `CREATE SCHEMA IF NOT EXISTS "rebase"`
25
+ * unconditionally, so the schema is always in the desired state and the diff is
26
+ * empty. (It used to appear only when some collection happened to declare
27
+ * `schema: "rebase"` — true for the scaffold's users collection, and not a
28
+ * property anything guaranteed.) `db push` additionally excludes the whole
29
+ * schema from the declarative apply.
30
+ *
31
+ * See `@rebasepro/types`' `rls-functions` for why the functions moved out of
32
+ * `auth` at all: the short version is that the name was Supabase's, and
33
+ * `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` cannot be applied over
34
+ * Supabase's `RETURNS uuid` — Postgres refuses, and the refusal used to be
35
+ * swallowed.
36
+ */
37
+ /**
38
+ * The bootstrap as individual statements.
39
+ *
40
+ * Kept as an array because the two consumers need different shapes and only one
41
+ * of them can take a multi-command string: the migration preamble is written to
42
+ * a file and replayed by Atlas, but the boot path runs through drizzle, whose
43
+ * node-postgres handle speaks the extended query protocol and rejects more than
44
+ * one command per call. Splitting a joined string back apart on `$$;` would be
45
+ * a parser for a problem that does not need one.
46
+ */
47
+ export declare const RLS_BOOTSTRAP_STATEMENTS: readonly string[];
48
+ /** The same statements as one script, for migration files and raw clients. */
49
+ export declare const RLS_BOOTSTRAP_SQL: string;
50
+ /**
51
+ * Removes the pre-1.0 `auth` schema, but only when Rebase is what put it there.
52
+ *
53
+ * ## Why this is safe against a Supabase database
54
+ *
55
+ * Two independent guards, and both have to pass:
56
+ *
57
+ * 1. **Each function is identified before it is dropped.** Ours returns `text`
58
+ * and reads the `app.uid` GUC; Supabase's returns `uuid` and reads
59
+ * `request.jwt.claims`. Nothing is dropped on a signature we did not write,
60
+ * so a Supabase database — where our `CREATE OR REPLACE` could never have
61
+ * succeeded in the first place, Postgres refusing to change a return type —
62
+ * matches nothing and this is a no-op.
63
+ * 2. **`DROP SCHEMA … RESTRICT`**, never CASCADE. If anything else at all still
64
+ * lives in `auth` (Supabase's `users` table, its other helpers), the drop
65
+ * fails and the schema stays. CASCADE here would be unrecoverable.
66
+ *
67
+ * ## Why it cannot run too early
68
+ *
69
+ * Postgres records a dependency from every RLS policy to the functions its body
70
+ * calls, so `DROP FUNCTION auth.uid()` fails for as long as a single policy
71
+ * still references it. That is the interlock, and it is load-bearing: the drop
72
+ * can only succeed once every policy has been recompiled to \`rebase.uid()\`.
73
+ * Callers therefore run this *after* applying policies, and treat a failure as
74
+ * "not yet — try again next boot" rather than as an error.
75
+ */
76
+ export declare const DROP_LEGACY_AUTH_SCHEMA_SQL = "\nDO $rebase_drop_legacy$\nDECLARE\n dropped_any boolean := false;\nBEGIN\n -- Each function is matched on its own result type and body, so a schema\n -- that merely shares the name keeps everything it has.\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'uid'\n AND pg_get_function_result(p.oid) = 'text'\n AND p.prosrc LIKE '%app.uid%'\n ) THEN\n DROP FUNCTION auth.uid();\n dropped_any := true;\n END IF;\n\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'jwt'\n AND pg_get_function_result(p.oid) = 'jsonb'\n AND p.prosrc LIKE '%app.jwt%'\n ) THEN\n DROP FUNCTION auth.jwt();\n dropped_any := true;\n END IF;\n\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'roles'\n AND pg_get_function_result(p.oid) = 'text'\n AND p.prosrc LIKE '%app.user_roles%'\n ) THEN\n DROP FUNCTION auth.roles();\n dropped_any := true;\n END IF;\n\n -- RESTRICT: only an empty schema goes. Anything else in there \u2014 including a\n -- Supabase installation left untouched above \u2014 keeps it.\n IF dropped_any THEN\n BEGIN\n EXECUTE 'DROP SCHEMA auth RESTRICT';\n EXCEPTION WHEN OTHERS THEN\n NULL;\n END;\n END IF;\nEND\n$rebase_drop_legacy$;\n";
77
+ /** Somebody's policy that still calls a pre-1.0 helper. */
78
+ export interface LegacyRlsDependent {
79
+ schema: string;
80
+ table: string;
81
+ policy: string;
82
+ }
83
+ /**
84
+ * Policies whose body still calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
85
+ *
86
+ * Postgres will not drop a function a policy depends on, so this is exactly the
87
+ * set standing between a database and losing the legacy schema. Rebase's own
88
+ * policies leave the list on the next push or boot, when they are recompiled —
89
+ * anything still here afterwards is hand-written, will never be recompiled by
90
+ * anybody, and is the reason the drop keeps being skipped. Silence there would
91
+ * leave an operator staring at a schema the release notes said would go.
92
+ */
93
+ export declare const LEGACY_RLS_DEPENDENTS_SQL = "\n SELECT n.nspname AS schema, c.relname AS \"table\", p.polname AS policy\n FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE pg_get_expr(p.polqual, p.polrelid) ~* '\\mauth\\.(uid|jwt|roles)\\s*\\('\n OR pg_get_expr(p.polwithcheck, p.polrelid) ~* '\\mauth\\.(uid|jwt|roles)\\s*\\('\n ORDER BY 1, 2, 3\n";
94
+ /** Somebody's *function* that still calls a pre-1.0 helper from its own body. */
95
+ export interface LegacyRlsFunctionDependent {
96
+ schema: string;
97
+ function: string;
98
+ }
99
+ /**
100
+ * Functions whose body calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
101
+ *
102
+ * This is the half `DROP FUNCTION ... RESTRICT` cannot see, and the reason it
103
+ * needs its own query. Postgres records a dependency for a *policy* that calls a
104
+ * function, which is why the drop is safe against the policies above — but a
105
+ * `LANGUAGE sql` function whose body is a **string literal** is not parsed when
106
+ * it is created, so nothing is recorded and `RESTRICT` has nothing to refuse on.
107
+ * The drop succeeds and the caller is left pointing at a function that no longer
108
+ * exists, which fails at *query* time rather than at boot.
109
+ *
110
+ * A downstream project building on these helpers is not hypothetical: the Rebase
111
+ * control plane defines `auth.is_org_member(uuid)` and `auth.is_org_admin(uuid)`
112
+ * in this very schema, each calling `auth.uid()` in a string body, and eleven of
113
+ * its row-level-security policies go through them. Every one of those would have
114
+ * started failing the first time a recompile left no policy referencing
115
+ * `auth.uid()` directly — the drop's own precondition.
116
+ *
117
+ * Matching on the body text is the only option available, and it is deliberately
118
+ * broad: a false positive costs a schema that stays one release longer and says
119
+ * why, while a false negative costs somebody their policies.
120
+ */
121
+ export declare const LEGACY_RLS_FUNCTION_DEPENDENTS_SQL = "\n SELECT n.nspname AS schema, p.proname AS function\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')\n AND NOT (n.nspname = 'auth' AND p.proname IN ('uid', 'jwt', 'roles'))\n AND p.prosrc ~* '\\mauth\\.(uid|jwt|roles)\\s*\\('\n ORDER BY 1, 2\n";
122
+ /**
123
+ * Retire the pre-1.0 `auth` schema, reporting what is holding it back.
124
+ *
125
+ * The shared implementation behind the CLI's post-push step and the runtime's
126
+ * post-policy step. Both used to just fire {@link DROP_LEGACY_AUTH_SCHEMA_SQL}
127
+ * and swallow whatever came back, which is right for the ordinary case — a
128
+ * table not recompiled *yet* — and wrong for the one that never resolves: a
129
+ * hand-written policy nothing will ever rewrite. Then the schema stays forever
130
+ * and nothing ever says why.
131
+ */
132
+ export declare function dropLegacyAuthSchema(run: (sql: string) => Promise<Record<string, unknown>[]>, report: {
133
+ info: (m: string) => void;
134
+ warn: (m: string) => void;
135
+ }): Promise<void>;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The one place a collection's `search` block becomes SQL.
3
+ *
4
+ * Four things describe a Postgres table in this codebase — the DDL generator,
5
+ * the Drizzle schema generator, the runtime table builder for BaaS mode, and
6
+ * the boot-time schema ensure — and each of them has, at some point, described
7
+ * a column differently from the others. The `varchar(255)` note in
8
+ * `generate-postgres-ddl-logic` is one such scar: the same property produced a
9
+ * capped column down one path and an uncapped one down the other, and nothing
10
+ * failed until a user hit the cap.
11
+ *
12
+ * So the search column is not implemented four times. It is computed once,
13
+ * here, and every generator renders the same {@link SearchColumnSpec}. There is
14
+ * a test asserting exactly that (`search-column-contract.test.ts`); the point of
15
+ * this module is that the test has something to assert *about*.
16
+ *
17
+ * ## Why the expressions look the way they do
18
+ *
19
+ * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
20
+ * Postgres is stricter here than intuition. Verified against PostgreSQL 18:
21
+ *
22
+ * | expression | immutable |
23
+ * |-----------------------------------------|-----------|
24
+ * | `to_tsvector('spanish', col)` | yes |
25
+ * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
26
+ * | `array_to_string(col, ' ')` | **no** |
27
+ * | `col::text` on `text[]` | **no** |
28
+ * | `to_jsonb(col)` | **no** |
29
+ * | `unaccent(col)` | **no** — dictionary lookup is STABLE |
30
+ * | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
31
+ * | `setweight(...) || setweight(...)` | yes |
32
+ *
33
+ * Three of the four things a real search column needs are therefore unavailable
34
+ * directly, which is why {@link searchHelperFunctions} exists: each wraps a
35
+ * stable built-in in an SQL function declared IMMUTABLE. That declaration is a
36
+ * promise, and it is a true one for these three — array joining, JSON string
37
+ * extraction and accent folding are all deterministic for a given input; the
38
+ * built-ins are marked stable only because they must account for element types
39
+ * and dictionaries in general.
40
+ *
41
+ * The alternative was to skip `unaccent` and text arrays entirely. That is not
42
+ * a real option in an accented language: Postgres stems `auditoría` to
43
+ * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
44
+ * typed without accents misses every row that carries them.
45
+ */
46
+ import { CollectionConfig, SearchConfig, SearchWeight } from "@rebasepro/types";
47
+ /**
48
+ * Names of the helper functions. Frozen: they are recorded in the stored
49
+ * generation expression of every search column ever created, so renaming one
50
+ * orphans every table that already has a search column.
51
+ */
52
+ export declare const SEARCH_TEXT_FN = "public.rebase_search_text";
53
+ export declare const SEARCH_UNACCENT_FN = "public.rebase_search_unaccent";
54
+ /** How a declared path reaches text, which decides the SQL that extracts it. */
55
+ type FieldKind = "text" | "text_array" | "jsonb";
56
+ /** One resolved field: where it lives, how to read it, what it is worth. */
57
+ export interface ResolvedSearchField {
58
+ /** The path exactly as the author wrote it, for error messages. */
59
+ path: string;
60
+ /** The physical column the path starts at. */
61
+ column: string;
62
+ /** Dotted remainder addressed inside a JSONB column, if any. */
63
+ jsonPath: string[];
64
+ kind: FieldKind;
65
+ weight: SearchWeight;
66
+ /** The `setweight(to_tsvector(…), 'X')` term this field contributes. */
67
+ sql: string;
68
+ /** The plain-text term this field contributes, for the fuzzy column. */
69
+ textSql: string;
70
+ }
71
+ /** Everything the generators need to render one collection's search column. */
72
+ export interface SearchColumnSpec {
73
+ schema: string;
74
+ table: string;
75
+ /** The generated `tsvector` column. */
76
+ column: string;
77
+ language: string;
78
+ unaccent: boolean;
79
+ fields: ResolvedSearchField[];
80
+ /** Body of `GENERATED ALWAYS AS ( … ) STORED` for the tsvector column. */
81
+ expression: string;
82
+ indexName: string;
83
+ /** Extensions that must exist before the column can be created. */
84
+ extensions: string[];
85
+ fuzzy?: {
86
+ column: string;
87
+ expression: string;
88
+ indexName: string;
89
+ threshold: number;
90
+ };
91
+ }
92
+ /** Raised when a `search` block names something that cannot be searched. */
93
+ export declare class SearchConfigError extends Error {
94
+ constructor(message: string);
95
+ }
96
+ /** The `search` block of a collection, or undefined when it has none. */
97
+ export declare const getSearchConfig: (collection: CollectionConfig) => SearchConfig | undefined;
98
+ /**
99
+ * Refuse a `search` block on a collection this engine does not store.
100
+ *
101
+ * The type only permits one on a `PostgresCollectionConfig`, so TypeScript
102
+ * already stops the ordinary case. This catches the rest — a JS config, a cast,
103
+ * a collection whose `engine` was changed after the block was written — because
104
+ * the alternative is the exact failure the block exists to prevent: a developer
105
+ * who declared what to index, saw no error, and got the substring fallback.
106
+ *
107
+ * Called with *every* collection, before the Postgres ones are filtered out.
108
+ */
109
+ export declare const assertSearchIsPostgresOnly: (collections: CollectionConfig[]) => void;
110
+ /**
111
+ * Build the full spec for a collection, or undefined when it has not opted in.
112
+ *
113
+ * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
114
+ * at boot surface that as a startup failure — a search block that half-works is
115
+ * worse than one that refuses.
116
+ */
117
+ export declare const buildSearchColumnSpec: (collection: CollectionConfig) => SearchColumnSpec | undefined;
118
+ /**
119
+ * The IMMUTABLE wrappers the generated expressions call.
120
+ *
121
+ * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
122
+ * than an error, and idempotent for the same reason every other boot-time DDL
123
+ * statement here is.
124
+ *
125
+ * The bodies are stable built-ins wrapped in an immutable promise — see the
126
+ * module comment for why that promise is sound. `STRICT` matters: it makes NULL
127
+ * in mean NULL out without executing the body, which is what the `coalesce` at
128
+ * each call site then absorbs.
129
+ */
130
+ export declare const searchHelperFunctions: (spec: SearchColumnSpec) => string[];
131
+ /**
132
+ * `CREATE EXTENSION` statements the spec's expressions depend on.
133
+ *
134
+ * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
135
+ * `CREATE EXTENSION` installs into the first schema on `search_path`, which
136
+ * defaults to `"$user", public` — and the scaffold's database role is named
137
+ * `rebase`, the same as the schema the generator creates one statement earlier.
138
+ * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
139
+ * dictionary in `rebase`, and every reference to `public.unaccent` below fails
140
+ * with "text search dictionary does not exist". Observed, not theorised.
141
+ */
142
+ export declare const searchExtensionStatements: (spec: SearchColumnSpec) => string[];
143
+ /** The column definition as it appears inside `CREATE TABLE`. */
144
+ export declare const searchColumnDefinition: (spec: SearchColumnSpec) => string;
145
+ /** The fuzzy column definition, when the spec asks for one. */
146
+ export declare const fuzzyColumnDefinition: (spec: SearchColumnSpec) => string | undefined;
147
+ /**
148
+ * Index statements for the spec.
149
+ *
150
+ * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
151
+ * SQL file replayed as one unit — a migration, or `search.sql` — where a
152
+ * concurrent build is not allowed. The boot-time ensure path runs statement by
153
+ * statement against tables that are live and populated, and uses the
154
+ * concurrent form instead; see `ensureSearchColumns`.
155
+ */
156
+ export declare const searchIndexStatements: (spec: SearchColumnSpec) => string[];
157
+ /**
158
+ * Marker on the comment of every generated search column this module creates.
159
+ *
160
+ * Versioned because the fingerprint below is only comparable against itself: a
161
+ * future change to how it is computed has to read as "not stamped by this
162
+ * version" rather than as drift on every existing column.
163
+ */
164
+ export declare const SEARCH_STAMP_PREFIX = "rebase:search:v1:";
165
+ /**
166
+ * A stable fingerprint of one generated column's expression.
167
+ *
168
+ * Why a stamp rather than reading the expression back: Postgres stores a
169
+ * generated column's expression *parsed*, and hands it back deparsed — casts
170
+ * made explicit, identifiers requoted, schema qualifications added or dropped
171
+ * according to `search_path`. Comparing that text to the text we generated
172
+ * would report drift on wording, and this comparison decides whether a boot
173
+ * refuses, so a false positive is an outage. The stamp is written by the same
174
+ * code that writes the column, so equality means what it says.
175
+ */
176
+ export declare const searchExpressionFingerprint: (expression: string) => string;
177
+ /** One generated column, with the fingerprint that identifies its expression. */
178
+ export interface SearchColumnStamp {
179
+ column: string;
180
+ /** The expression the column is generated from. */
181
+ expression: string;
182
+ fingerprint: string;
183
+ /** `COMMENT ON COLUMN …`, which is where the fingerprint is recorded. */
184
+ sql: string;
185
+ }
186
+ /**
187
+ * The stamps for a spec's generated columns — one per column, never shared.
188
+ *
189
+ * Per column on purpose: turning `fuzzy` on adds a second column and changes
190
+ * nothing about the first, and a spec-wide fingerprint would report the
191
+ * untouched `tsvector` column as drifted and refuse a boot over a change that
192
+ * is purely additive.
193
+ */
194
+ export declare const searchColumnStamps: (spec: SearchColumnSpec) => SearchColumnStamp[];
195
+ /**
196
+ * The same drift check as the boot ensure, for the SQL file.
197
+ *
198
+ * Needed because {@link searchColumnStamps} would otherwise *launder* drift on
199
+ * the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
200
+ * exists, so a re-generated `search.sql` would stamp a stale column with the
201
+ * new block's fingerprint and the next boot would find them in agreement.
202
+ * Guarding first means the file refuses instead — `rebase db push` is attended,
203
+ * and the operator reading the failure is the person who changed the block.
204
+ */
205
+ export declare const searchStampGuards: (spec: SearchColumnSpec) => string[];
206
+ /**
207
+ * The index names the spec creates.
208
+ *
209
+ * Needed by name, not just by statement, so Atlas can be told to exclude them
210
+ * from its diff — see `searchExcludePatterns`.
211
+ */
212
+ export declare const searchIndexNames: (spec: SearchColumnSpec) => string[];
213
+ /**
214
+ * The generated column names a collection's search block adds, if any.
215
+ *
216
+ * These are physical columns on the table, so `SELECT *` returns them. They are
217
+ * an index in column form — a list of lexeme positions, or a concatenation of
218
+ * every searchable field on the row — and nothing outside the query planner has
219
+ * any use for them. Left in, every list response carries a second, larger copy
220
+ * of the row's text.
221
+ */
222
+ export declare const searchColumnNames: (collection: CollectionConfig) => string[];
223
+ /**
224
+ * True for a column whose type only ever holds a search index.
225
+ *
226
+ * Independent of any collection config on purpose: an introspected database
227
+ * (BaaS mode) can carry a `tsvector` column this framework never created —
228
+ * Pagila's `film.fulltext` is the canonical one — and it should not be returned
229
+ * to callers either. `isDerivedIndexColumn` already keeps such a column out of
230
+ * the *properties*; this keeps it out of the *rows*.
231
+ */
232
+ export declare const isSearchIndexColumn: (column: {
233
+ getSQLType?: () => string;
234
+ }) => boolean;
235
+ /**
236
+ * A drizzle select projection over `table` with the search columns dropped.
237
+ *
238
+ * Returns undefined when nothing needs dropping, so the common case keeps using
239
+ * a plain `select()` and this stays invisible in the generated SQL.
240
+ */
241
+ export declare const visibleColumnProjection: (tableColumns: Record<string, {
242
+ getSQLType?: () => string;
243
+ }> | undefined, collection?: CollectionConfig) => Record<string, unknown> | undefined;
244
+ /** The same exclusion as a drizzle `db.query` `columns` denylist. */
245
+ export declare const hiddenColumnsOption: (tableColumns: Record<string, {
246
+ getSQLType?: () => string;
247
+ }> | undefined, collection?: CollectionConfig) => Record<string, false> | undefined;
248
+ export {};
@@ -26,6 +26,20 @@ export interface PolicyRef {
26
26
  hasUsing: boolean;
27
27
  /** Whether a WITH CHECK clause is present at all (not what it says). */
28
28
  hasWithCheck: boolean;
29
+ /**
30
+ * PERMISSIVE or RESTRICTIVE — the `AS` clause.
31
+ *
32
+ * An exact catalogue value on both sides, so it belongs with roles and
33
+ * command rather than with the expression text. It matters more than either:
34
+ * permissive policies are ORed together and restrictive ones ANDed, so a rule
35
+ * declared `mode: "restrictive"` whose live policy is PERMISSIVE has had its
36
+ * gate turned from a requirement into an alternative — the maximally
37
+ * permissive way for this to be wrong.
38
+ *
39
+ * The DDL regex captured this from the start and the destructuring threw it
40
+ * away; `pg_policies.permissive` was never selected.
41
+ */
42
+ mode?: "PERMISSIVE" | "RESTRICTIVE";
29
43
  /**
30
44
  * The live clause text, when read from `pg_policies`. Present only for live
31
45
  * policies (the expected side is parsed from DDL and does not carry it).
@@ -62,6 +76,26 @@ export interface PolicyDrift {
62
76
  policy: PolicyRef;
63
77
  reason: string;
64
78
  }[];
79
+ /**
80
+ * A table the collections describe whose RLS switch is off.
81
+ *
82
+ * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in
83
+ * `pg_policies` untouched, so before this category every expected policy
84
+ * still matched on name, roles, command and clause presence and the checker
85
+ * reported clean — on a table Postgres was applying no filter to at all.
86
+ * Requests run as `rebase_user`, which holds full DML, so the table is wide
87
+ * open while `doctor` certifies it.
88
+ *
89
+ * `forced` reports `relforcerowsecurity`, which is what also subjects the
90
+ * table's *owner* to its policies. Its absence is not drift on its own —
91
+ * Rebase does not connect as the owner in the request path — so it is
92
+ * reported for context rather than raised as a failure.
93
+ */
94
+ rlsDisabled: {
95
+ schema: string;
96
+ table: string;
97
+ forced: boolean;
98
+ }[];
65
99
  }
66
100
  export interface Queryable {
67
101
  query<R>(text: string, values?: unknown[]): Promise<{
@@ -1,5 +1,6 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { SecurityRule } from "@rebasepro/types";
3
+ import { REBASE_USER_ROLE } from "@rebasepro/common";
3
4
  /**
4
5
  * Unified RLS enforcement — the "user context vs server context" model.
5
6
  *
@@ -13,9 +14,14 @@ import { SecurityRule } from "@rebasepro/types";
13
14
  * are validation/side-effects, not a security boundary.
14
15
  *
15
16
  * - **Server context** — the base (owner) connection: auth flows, migrations,
16
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
17
- * owner it bypasses RLS. This is the trusted plane, equivalent to
18
- * Supabase's `service_role`.
17
+ * and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted
18
+ * plane, equivalent to Supabase's `service_role`.
19
+ *
20
+ * `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes
21
+ * that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user
22
+ * context above — `rebase_user`, `app.uid = 'service'`, policies evaluated —
23
+ * and clears the default policies through their admin arm rather than the
24
+ * `auth.uid() IS NULL` one.
19
25
  *
20
26
  * This module provides the three pieces:
21
27
  *
@@ -32,8 +38,14 @@ import { SecurityRule } from "@rebasepro/types";
32
38
  * self-creates the `auth` schema and functions) — enforcement is default-on,
33
39
  * not an operator opt-in.
34
40
  */
35
- /** The restricted role every authenticated (user-context) request runs as. */
36
- export declare const REBASE_USER_ROLE = "rebase_user";
41
+ /**
42
+ * The restricted role every authenticated (user-context) request runs as.
43
+ *
44
+ * Re-exported, not re-declared: the same name is needed by
45
+ * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
46
+ * fail as a silent no-op rather than an error.
47
+ */
48
+ export { REBASE_USER_ROLE };
37
49
  /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
38
50
  export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
39
51
  /** Minimal transaction surface needed by {@link applyAuthContext}. */
@@ -55,6 +67,34 @@ export interface AuthContext {
55
67
  /** Raw roles as carried on the user (strings or `{ id }` objects). */
56
68
  roles: unknown[];
57
69
  }
70
+ /**
71
+ * Warn when the connection role shares its name with an existing schema.
72
+ *
73
+ * Postgres resolves unqualified names through `search_path`, which defaults to
74
+ * `"$user", public` — and `$user` is the connection ROLE. When a schema of that
75
+ * name exists it sits ahead of `public`, so every unqualified statement
76
+ * silently operates on it instead:
77
+ *
78
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
79
+ *
80
+ * Nothing errors. You get a second table of the same name in the wrong schema,
81
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
82
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
83
+ * has a whole branch dedicated to recognising the symptom after the fact.
84
+ *
85
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
86
+ * every template named the database role `rebase` too. The scaffold uses
87
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
88
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
89
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
90
+ * a hand-written migration — because the hazard is a property of the two NAMES,
91
+ * not of any one connection.
92
+ *
93
+ * A warning rather than a boot failure: the database works, the framework's own
94
+ * traffic is pinned, and refusing to start over a naming choice a user may have
95
+ * inherited would be worse than the risk.
96
+ */
97
+ export declare function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void>;
58
98
  export declare function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture>;
59
99
  /**
60
100
  * Human-actionable instructions for when the connection cannot provision the
@@ -123,6 +163,22 @@ export declare function warnOnAnonymousGrants(collections: {
123
163
  slug?: string;
124
164
  securityRules?: readonly SecurityRule[];
125
165
  }[]): void;
166
+ /**
167
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
168
+ *
169
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
170
+ * database, so nothing is broken and no policy is wrong — which is exactly why
171
+ * this has to be said out loud. A silent rewrite that works forever is not a
172
+ * migration, it is a second supported spelling nobody wrote down, and the next
173
+ * person to read those rules will copy the old one.
174
+ *
175
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
176
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
177
+ */
178
+ export declare function warnOnLegacyRlsFunctions(collections: {
179
+ slug?: string;
180
+ securityRules?: readonly SecurityRule[];
181
+ }[]): void;
126
182
  /**
127
183
  * Reject `pgRoles` that this server can never satisfy.
128
184
  *
@@ -64,6 +64,18 @@ export declare class FetchService {
64
64
  * and skips rows rather than erroring. The guesses stay, last, for a
65
65
  * caller that hands over no collection to resolve against.
66
66
  */
67
+ /**
68
+ * The ORDER BY target, which may be relevance rather than a column.
69
+ *
70
+ * `_score` is only meaningful for a collection that declared a `search`
71
+ * block *and* for a request that carried a search string — ranking rows
72
+ * against no query ranks them all at zero. Outside those two conditions it
73
+ * is an unknown field and gets the same 400 as any other typo, which is the
74
+ * behaviour that matters: a sort that is silently dropped returns 200 with
75
+ * rows in arbitrary order, and paging over that repeats and skips rows.
76
+ */
77
+ static readonly SCORE_FIELD = "_score";
78
+ private resolveOrderTarget;
67
79
  private resolveOrderByField;
68
80
  /**
69
81
  * Build the `with` config for Drizzle's relational query API.
@@ -132,6 +144,7 @@ export declare class FetchService {
132
144
  offset?: number;
133
145
  startAfter?: Record<string, unknown>;
134
146
  searchString?: string;
147
+ searchExplain?: boolean;
135
148
  databaseId?: string;
136
149
  vectorSearch?: VectorSearchParams;
137
150
  logical?: LogicalCondition;
@@ -177,10 +190,21 @@ export declare class FetchService {
177
190
  */
178
191
  searchRows<M extends Record<string, unknown>>(collectionPath: string, searchString: string, options?: {
179
192
  filter?: FilterValues<Extract<keyof M, string>>;
193
+ /**
194
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
195
+ *
196
+ * `fetchRowsWithConditions` has always applied one; it was missing
197
+ * from this signature, so a realtime search subscription carrying a
198
+ * group could not pass it on and served every row matching the text
199
+ * that RLS allowed.
200
+ */
201
+ logical?: LogicalCondition;
180
202
  orderBy?: string;
181
203
  order?: "desc" | "asc";
182
204
  limit?: number;
183
205
  databaseId?: string;
206
+ /** Ask each row which declared search field matched. */
207
+ searchExplain?: boolean;
184
208
  }): Promise<Record<string, unknown>[]>;
185
209
  /**
186
210
  * Count rows in a collection
@@ -1,4 +1,5 @@
1
1
  import { RelationService } from "./RelationService";
2
+ import { RelationWriteService } from "./RelationWriteService";
2
3
  import { FetchService } from "./FetchService";
3
4
  import { DrizzleClient } from "../interfaces";
4
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -9,25 +10,15 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
9
10
  export declare class PersistService {
10
11
  private db;
11
12
  private registry;
13
+ /** Reads: whether a row is under a parent, the key a link joins on. */
12
14
  private relationService;
15
+ /** Writes: junction membership, foreign-key stamping, links. */
16
+ private relationWrites;
13
17
  private fetchService;
14
18
  constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
15
19
  /**
16
- * Explain a write that matched no rows.
17
- *
18
- * Row-level security filters UPDATE and DELETE through the policy's USING
19
- * clause instead of raising: a denied write is reported by Postgres exactly
20
- * like a successful one that happened to match nothing. Left unchecked, a
21
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
22
- * the row is untouched.
23
- *
24
- * Re-reading the target over the *same* RLS-scoped handle separates the two
25
- * cases. A visible row means the policy rejected the write (403); an
26
- * invisible one means there is nothing there to write for this caller (404,
27
- * matching what a GET would say). The re-read is bound by the caller's own
28
- * policies, so it discloses nothing a plain read wouldn't.
29
- *
30
- * Only reached when zero rows matched, so the happy path pays nothing.
20
+ * Explain a row write that matched nothing — see {@link explainZeroRowWrite}
21
+ * for why a zero-row write cannot be reported as success.
31
22
  */
32
23
  private explainZeroRowWrite;
33
24
  /**
@@ -39,8 +30,14 @@ export declare class PersistService {
39
30
  */
40
31
  deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
41
32
  /**
42
- * The column on the *target* table that records the parent, for a create
43
- * under a nested one-to-many path.
33
+ * The field on the *target* row that records the parent, for a create under
34
+ * a nested one-to-many path.
35
+ *
36
+ * A **field**, not the column: the value is stamped into the caller's
37
+ * payload, which is keyed by wire names — `authorId`, never the `author_id`
38
+ * the relation names its link by. Stamping the column instead put a key on
39
+ * the payload that no property answers to, and `strictWrites` rejected the
40
+ * request the framework had just written to.
44
41
  *
45
42
  * Returns `undefined` when the link is not a column at all (a multi-hop
46
43
  * `joinPath`), so the caller writes the row without stamping anything.
@@ -66,6 +63,13 @@ export declare class PersistService {
66
63
  * Get the RelationService instance for external use
67
64
  */
68
65
  getRelationService(): RelationService;
66
+ /**
67
+ * The write half, for external use. Separate from
68
+ * {@link getRelationService} because they are separate objects now: reads
69
+ * answer questions, writes change rows, and the callers of one are not the
70
+ * callers of the other.
71
+ */
72
+ getRelationWriteService(): RelationWriteService;
69
73
  /**
70
74
  * Get the FetchService instance for external use
71
75
  */