@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 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 (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -427,6 +427,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
427
427
  identity: undefined;
428
428
  generated: undefined;
429
429
  }, {}, {}>;
430
+ aal: import("drizzle-orm/pg-core").PgColumn<{
431
+ name: "aal";
432
+ tableName: "refresh_tokens";
433
+ dataType: "string";
434
+ columnType: "PgText";
435
+ data: string;
436
+ driverParam: string;
437
+ notNull: false;
438
+ hasDefault: false;
439
+ isPrimaryKey: false;
440
+ isAutoincrement: false;
441
+ hasRuntimeDefault: false;
442
+ enumValues: [string, ...string[]];
443
+ baseColumn: never;
444
+ identity: undefined;
445
+ generated: undefined;
446
+ }, {}, {}>;
430
447
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
431
448
  name: "user_agent";
432
449
  tableName: "refresh_tokens";
@@ -880,6 +897,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
880
897
  identity: undefined;
881
898
  generated: undefined;
882
899
  }, {}, {}>;
900
+ lastUsedCounter: import("drizzle-orm/pg-core").PgColumn<{
901
+ name: "last_used_counter";
902
+ tableName: "mfa_factors";
903
+ dataType: "number";
904
+ columnType: "PgBigInt53";
905
+ data: number;
906
+ driverParam: string | number;
907
+ notNull: false;
908
+ hasDefault: false;
909
+ isPrimaryKey: false;
910
+ isAutoincrement: false;
911
+ hasRuntimeDefault: false;
912
+ enumValues: undefined;
913
+ baseColumn: never;
914
+ identity: undefined;
915
+ generated: undefined;
916
+ }, {}, {}>;
883
917
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
884
918
  name: "created_at";
885
919
  tableName: "mfa_factors";
@@ -1006,6 +1040,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
1006
1040
  identity: undefined;
1007
1041
  generated: undefined;
1008
1042
  }, {}, {}>;
1043
+ attempts: import("drizzle-orm/pg-core").PgColumn<{
1044
+ name: "attempts";
1045
+ tableName: "mfa_challenges";
1046
+ dataType: "number";
1047
+ columnType: "PgInteger";
1048
+ data: number;
1049
+ driverParam: string | number;
1050
+ notNull: true;
1051
+ hasDefault: true;
1052
+ isPrimaryKey: false;
1053
+ isAutoincrement: false;
1054
+ hasRuntimeDefault: false;
1055
+ enumValues: undefined;
1056
+ baseColumn: never;
1057
+ identity: undefined;
1058
+ generated: undefined;
1059
+ }, {}, {}>;
1009
1060
  expiresAt: import("drizzle-orm/pg-core").PgColumn<{
1010
1061
  name: "expires_at";
1011
1062
  tableName: "mfa_challenges";
@@ -1642,6 +1693,23 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1642
1693
  identity: undefined;
1643
1694
  generated: undefined;
1644
1695
  }, {}, {}>;
1696
+ aal: import("drizzle-orm/pg-core").PgColumn<{
1697
+ name: "aal";
1698
+ tableName: "refresh_tokens";
1699
+ dataType: "string";
1700
+ columnType: "PgText";
1701
+ data: string;
1702
+ driverParam: string;
1703
+ notNull: false;
1704
+ hasDefault: false;
1705
+ isPrimaryKey: false;
1706
+ isAutoincrement: false;
1707
+ hasRuntimeDefault: false;
1708
+ enumValues: [string, ...string[]];
1709
+ baseColumn: never;
1710
+ identity: undefined;
1711
+ generated: undefined;
1712
+ }, {}, {}>;
1645
1713
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
1646
1714
  name: "user_agent";
1647
1715
  tableName: "refresh_tokens";
@@ -2095,6 +2163,23 @@ export declare const mfaFactors: import("drizzle-orm/pg-core").PgTableWithColumn
2095
2163
  identity: undefined;
2096
2164
  generated: undefined;
2097
2165
  }, {}, {}>;
2166
+ lastUsedCounter: import("drizzle-orm/pg-core").PgColumn<{
2167
+ name: "last_used_counter";
2168
+ tableName: "mfa_factors";
2169
+ dataType: "number";
2170
+ columnType: "PgBigInt53";
2171
+ data: number;
2172
+ driverParam: string | number;
2173
+ notNull: false;
2174
+ hasDefault: false;
2175
+ isPrimaryKey: false;
2176
+ isAutoincrement: false;
2177
+ hasRuntimeDefault: false;
2178
+ enumValues: undefined;
2179
+ baseColumn: never;
2180
+ identity: undefined;
2181
+ generated: undefined;
2182
+ }, {}, {}>;
2098
2183
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
2099
2184
  name: "created_at";
2100
2185
  tableName: "mfa_factors";
@@ -2221,6 +2306,23 @@ export declare const mfaChallenges: import("drizzle-orm/pg-core").PgTableWithCol
2221
2306
  identity: undefined;
2222
2307
  generated: undefined;
2223
2308
  }, {}, {}>;
2309
+ attempts: import("drizzle-orm/pg-core").PgColumn<{
2310
+ name: "attempts";
2311
+ tableName: "mfa_challenges";
2312
+ dataType: "number";
2313
+ columnType: "PgInteger";
2314
+ data: number;
2315
+ driverParam: string | number;
2316
+ notNull: true;
2317
+ hasDefault: true;
2318
+ isPrimaryKey: false;
2319
+ isAutoincrement: false;
2320
+ hasRuntimeDefault: false;
2321
+ enumValues: undefined;
2322
+ baseColumn: never;
2323
+ identity: undefined;
2324
+ generated: undefined;
2325
+ }, {}, {}>;
2224
2326
  expiresAt: import("drizzle-orm/pg-core").PgColumn<{
2225
2327
  name: "expires_at";
2226
2328
  tableName: "mfa_challenges";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What the RLS checks concluded.
3
+ *
4
+ * `unchecked` exists because "we could not look" and "we looked and it is fine"
5
+ * used to be reported identically: a collections path that did not resolve made
6
+ * the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`
7
+ * early-returned an empty diff, and the gate printed
8
+ * `✓ RLS policies match your collections` having compared zero policies against
9
+ * zero collections. Any exception at all — a collection file that throws on
10
+ * import, a `pg_policies` read the CI role is not granted, a connection reset —
11
+ * did the same thing through a `warn`, and exited 0.
12
+ */
13
+ export type PolicyCheckStatus = "ok" | "problems" | "unchecked";
14
+ /**
15
+ * The exit code for `rebase doctor --policies`.
16
+ *
17
+ * A gate that could not run has not passed. Only a completed, clean check
18
+ * exits 0 — anything else, including "we never opened a connection", is a
19
+ * failure, or the flag certifies a database nobody looked at.
20
+ */
21
+ export declare function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1;
22
+ /**
23
+ * Policies actually deployed vs the ones the collections describe, plus policy
24
+ * roles this server could never satisfy.
25
+ *
26
+ * Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.
27
+ */
28
+ export declare function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus>;
@@ -2,7 +2,7 @@ import { CollectionConfig, Property } from "@rebasepro/types";
2
2
  export type IssueSeverity = "error" | "warning" | "info";
3
3
  export interface DoctorIssue {
4
4
  severity: IssueSeverity;
5
- category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
5
+ category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated" | "sdk_ungeneratable";
6
6
  table?: string;
7
7
  column?: string;
8
8
  expected?: string;
@@ -10,21 +10,46 @@ export interface DoctorIssue {
10
10
  message: string;
11
11
  fix: string;
12
12
  }
13
+ export interface DoctorPhase {
14
+ passed: boolean;
15
+ issues: DoctorIssue[];
16
+ /**
17
+ * Why this phase never ran, when it did not.
18
+ *
19
+ * A check that did not happen is a third state, not a passing one. While a
20
+ * skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
21
+ * `✅ Collections → Database: In sync` and counted towards
22
+ * `✓ All schemas are in sync!` — so a project whose connection string was
23
+ * spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
24
+ * ticks and exit 0 against a database with no tables in it.
25
+ */
26
+ skipped?: string;
27
+ /**
28
+ * Why this phase had nothing to compare against, when it had nothing.
29
+ *
30
+ * Distinct from `skipped`, which means "the check could not run, and that
31
+ * is probably worth fixing". This one means "the artifact is optional and
32
+ * you have not asked for it": no drift is possible, so the run is still a
33
+ * clean bill of health.
34
+ *
35
+ * It exists because the alternative was a contradiction. The typed-SDK
36
+ * phase returned `{ passed: true }` when `generated/sdk/database.types.ts`
37
+ * did not exist, so a fresh project's report read
38
+ * `✅ Collections → SDK Types: In sync` directly above
39
+ * `ℹ Typed SDK not generated (optional).` — one line calling a file
40
+ * synchronised and the next saying it is absent. "In sync" is a claim about
41
+ * a comparison, and no comparison happened.
42
+ */
43
+ notApplicable?: string;
44
+ }
13
45
  export interface DoctorReport {
14
- collectionsToSchema: {
15
- passed: boolean;
16
- issues: DoctorIssue[];
17
- };
18
- collectionsToSdk: {
19
- passed: boolean;
20
- issues: DoctorIssue[];
21
- };
22
- schemaToDatabase: {
23
- passed: boolean;
24
- issues: DoctorIssue[];
25
- };
46
+ collectionsToSchema: DoctorPhase;
47
+ collectionsToSdk: DoctorPhase;
48
+ schemaToDatabase: DoctorPhase;
26
49
  summary: {
27
50
  passed: number;
51
+ skipped: number;
52
+ notApplicable: number;
28
53
  warnings: number;
29
54
  errors: number;
30
55
  };
@@ -37,18 +62,9 @@ export declare function getExpectedColumnType(prop: Property): string | null;
37
62
  * wrong thing.
38
63
  */
39
64
  export declare function loadCollections(collectionsPath: string): Promise<CollectionConfig[]>;
40
- export declare function checkCollectionsVsSchema(collections: CollectionConfig[], schemaFilePath: string): Promise<{
41
- passed: boolean;
42
- issues: DoctorIssue[];
43
- }>;
44
- export declare function checkCollectionsVsSdk(collections: CollectionConfig[], sdkFilePath: string): Promise<{
45
- passed: boolean;
46
- issues: DoctorIssue[];
47
- }>;
48
- export declare function checkCollectionsVsDatabase(collections: CollectionConfig[], databaseUrl: string): Promise<{
49
- passed: boolean;
50
- issues: DoctorIssue[];
51
- }>;
65
+ export declare function checkCollectionsVsSchema(collections: CollectionConfig[], schemaFilePath: string): Promise<DoctorPhase>;
66
+ export declare function checkCollectionsVsSdk(collections: CollectionConfig[], sdkFilePath: string): Promise<DoctorPhase>;
67
+ export declare function checkCollectionsVsDatabase(collections: CollectionConfig[], databaseUrl: string): Promise<DoctorPhase>;
52
68
  export declare function renderReport(report: DoctorReport): void;
53
69
  export declare function runDoctor(options: {
54
70
  collectionsPath: string;
@@ -43,18 +43,42 @@ export interface PolicyEnsureResult {
43
43
  table: string;
44
44
  reason: string;
45
45
  }[];
46
- /** Tables whose RLS could not be fully applied (fail closed). */
46
+ /**
47
+ * Tables that have RLS on but did not get every policy. They deny — RLS
48
+ * with no matching policy is deny-all — so they are safe but not servable.
49
+ */
47
50
  failures: {
48
51
  table: string;
49
52
  error: string;
50
53
  }[];
54
+ /**
55
+ * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.
56
+ *
57
+ * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`
58
+ * failing was recorded as a `failure` and reported with the same "it stays
59
+ * locked (denies)" wording as a failed policy — but the two are opposites.
60
+ * A policy statement failing leaves RLS on and the table denying. `enableRls`
61
+ * failing leaves RLS *off*, and the schema-wide grant to the user role has
62
+ * already been made by `ensureRlsEnforcement`, so the table is readable and
63
+ * writable by every authenticated request with no row filtering at all.
64
+ */
65
+ unsecured: {
66
+ table: string;
67
+ error: string;
68
+ grantWithdrawn: boolean;
69
+ }[];
70
+ /**
71
+ * Generated policies removed because no current rule produces them.
72
+ *
73
+ * A policy's name embeds a hash of the rule's semantics, so editing a rule
74
+ * does not update a policy — it creates a new one and abandons the old.
75
+ * Postgres ORs permissive policies, so the abandoned one keeps granting:
76
+ * a `USING (true)` tightened to an owner check went on admitting everyone,
77
+ * forever, while the deploy logged success.
78
+ *
79
+ * `db push` reconciles this, and cannot reach a managed tenant's in-cluster
80
+ * database — which is the reason this module exists. So boot has to do it.
81
+ */
82
+ orphansDropped: number;
51
83
  }
52
- /**
53
- * Bring the declared collections' RLS policies up to date. Returns what it did.
54
- *
55
- * Only tables that already exist are touched: the boot-time table creator runs
56
- * first, so anything still missing is a table this additive path is not allowed
57
- * to create (a junction, or a relation left to a migration). Enabling RLS on a
58
- * non-existent table would error, so those are recorded as skipped, not failed.
59
- */
60
84
  export declare function ensureCollectionPolicies(client: Queryable, collections: CollectionConfig[], log?: (message: string) => void): Promise<PolicyEnsureResult>;
@@ -54,9 +54,17 @@ export interface ExistingSchema {
54
54
  * constraint that then fails harmlessly as a duplicate.
55
55
  */
56
56
  constraints?: Set<string>;
57
+ /**
58
+ * `schema.table.column` → that column's comment, for the columns that have
59
+ * one. This is where a generated search column's fingerprint lives, so it
60
+ * is the only evidence that a `search` block has changed since the column
61
+ * was built. Absent is read as "no column is stamped", which plans a stamp
62
+ * and reports nothing as drifted.
63
+ */
64
+ columnComments?: Map<string, string>;
57
65
  }
58
66
  export interface EnsureAction {
59
- kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column" | "create-extension" | "create-function" | "create-index";
67
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column" | "create-extension" | "create-function" | "create-index" | "comment-column";
60
68
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
61
69
  target: string;
62
70
  sql: string;
@@ -76,6 +84,49 @@ export interface EnsurePlan {
76
84
  * resolving to nothing — which is indistinguishable from having no data.
77
85
  */
78
86
  legacyForeignKeys: LegacyForeignKey[];
87
+ /**
88
+ * Generated search columns whose `search` block has changed since they were
89
+ * built. Reported, never planned into `actions` — see
90
+ * {@link SearchColumnDrift} for why applying it is not this path's call.
91
+ */
92
+ searchDrift: SearchColumnDrift[];
93
+ /**
94
+ * Generated search columns that exist but carry no fingerprint — created
95
+ * before this check existed, or by `search.sql` on an older CLI. The plan
96
+ * stamps them so the *next* change is detectable; whether they match the
97
+ * current block cannot be known, which is what the caller reports.
98
+ */
99
+ searchAdopted: {
100
+ table: string;
101
+ column: string;
102
+ }[];
103
+ }
104
+ /**
105
+ * A generated search column built from a `search` block that has since changed.
106
+ *
107
+ * Reported instead of applied because the two ways to apply it are both worse
108
+ * than stopping. `ALTER COLUMN … SET EXPRESSION` exists only on PG17+ and
109
+ * rewrites the table either way; `DROP COLUMN` + `ADD COLUMN` rewrites it under
110
+ * an ACCESS EXCLUSIVE lock and rebuilds the GIN index. This module runs
111
+ * unattended against live customer data with nobody reading a diff — the same
112
+ * reason it withholds `SET NOT NULL` from an adopted table — so a multi-minute
113
+ * outage is not a decision it may take on its own.
114
+ *
115
+ * Not applying it silently is not an option either: that is the bug this
116
+ * detection exists for. A collection that added a field, flipped `unaccent` or
117
+ * raised a weight kept indexing the *old* set forever, and the only symptom was
118
+ * searches returning nothing for content plainly in the row.
119
+ */
120
+ export interface SearchColumnDrift {
121
+ /** `schema.table`. */
122
+ table: string;
123
+ column: string;
124
+ /** The fingerprint recorded on the column. */
125
+ found: string;
126
+ /** The fingerprint the current `search` block computes. */
127
+ expected: string;
128
+ /** The statements that would rebuild the column, for the operator to run. */
129
+ rebuild: string[];
79
130
  }
80
131
  /** A relation column whose old and new spellings both plausibly apply. */
81
132
  export interface LegacyForeignKey {
@@ -88,14 +139,17 @@ export interface LegacyForeignKey {
88
139
  }
89
140
  export interface EnsureOutcome extends EnsurePlan {
90
141
  /**
91
- * Constraints that could not be added always non-fatal.
142
+ * Actions that could not be applied and are non-fatal by nature.
92
143
  *
93
- * A foreign key can only fail on data that already violates it, and the
94
- * column it would police exists either way, so the collection still serves.
95
- * Refusing to boot over one would turn a pre-existing data problem into an
96
- * outage. Reported loudly instead.
144
+ * Two kinds qualify. A foreign key can only fail on data that already
145
+ * violates it, and the column it would police exists either way, so the
146
+ * collection still serves; refusing to boot over one would turn a
147
+ * pre-existing data problem into an outage. A column comment is the search
148
+ * fingerprint, which needs table ownership — losing it costs drift
149
+ * detection on the next boot, not the deployment. Both are reported loudly.
97
150
  */
98
151
  failures: {
152
+ kind: EnsureAction["kind"];
99
153
  target: string;
100
154
  error: string;
101
155
  }[];
@@ -1,2 +1,10 @@
1
- import { CollectionConfig } from "@rebasepro/types";
1
+ import { CollectionConfig, Property } from "@rebasepro/types";
2
+ /**
3
+ * The Drizzle column declaration a property compiles to, or `null` when the
4
+ * property puts no column on *this* table (an inverse relation, whose column
5
+ * lives on the target). Exported so it can be checked against its DDL twin
6
+ * `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
7
+ * with a database column and no Drizzle key.
8
+ */
9
+ export declare const getDrizzleColumn: (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]) => string | null;
2
10
  export declare const generateSchema: (allCollections: CollectionConfig[], stripPolicies?: boolean) => Promise<string>;
@@ -2,4 +2,11 @@ export interface InferenceResult {
2
2
  propType?: string;
3
3
  extra?: string;
4
4
  }
5
- export declare function inferPropertyFromData(columnName: string, pgDataType: string, currentPropType: string, sampleValues: unknown[], isPk: boolean): InferenceResult;
5
+ export declare function inferPropertyFromData(columnName: string, pgDataType: string, currentPropType: string, sampleValues: unknown[], isPk: boolean,
6
+ /**
7
+ * False when generating for a project without `@rebasepro/admin-types`, where
8
+ * `BaseProperty` declares no `admin` field and the block below would not
9
+ * compile. The type-level inferences (`propType`, `url`, `storage`) are
10
+ * unaffected — only the form-widget hints are dropped.
11
+ */
12
+ emitAdmin?: boolean): InferenceResult;
@@ -177,10 +177,59 @@ export interface GeneratedFile {
177
177
  * have no database to read constraints or row counts from, and they must keep
178
178
  * producing a valid collection.
179
179
  */
180
+ /**
181
+ * Which `defineCollection` — if any — the project being generated into can import.
182
+ *
183
+ * A bare `const x: PostgresCollectionConfig = { … }` annotation widens `properties`
184
+ * to `Record<string, …>`, and every key-shaped field in the admin block —
185
+ * `titleProperty`, `sort`, `propertiesOrder`, `listProperties`, `fixedFilter` — is
186
+ * derived from those keys. Annotated, they accept any string: introspection was
187
+ * emitting a `propertiesOrder` array that nothing checked, so renaming a column and
188
+ * re-introspecting left a stale key that compiled silently. `defineCollection` is
189
+ * the identity function whose `const P` type parameter keeps the keys literal, which
190
+ * is what turns that checking on.
191
+ *
192
+ * There are two of them and they are not interchangeable:
193
+ *
194
+ * - `admin-types` — `@rebasepro/admin-types`. Its index side-effect-imports
195
+ * `augment.ts`, so importing it is also what *declares* the `admin` block. Only a
196
+ * project that depends on the package can resolve it.
197
+ * - `common` — `@rebasepro/common`. Same key inference, no admin surface, no React
198
+ * anywhere in its graph (`scripts/headless-guard` lists it as core). This is the
199
+ * headless flavour.
200
+ * - `annotation` — neither package is declared, so neither import would resolve and
201
+ * the old annotation is the only honest thing to emit. Projects scaffolded before
202
+ * `@rebasepro/common` joined the headless config package land here.
203
+ *
204
+ * The last two emit **no admin block, on the collection or on any property**. That is
205
+ * not a downgrade: `@rebasepro/types` declares no `admin` field at all, so the block
206
+ * introspection used to emit was a type error in every headless project it was
207
+ * written into. See `packages/admin-types/src/augment.ts`.
208
+ */
209
+ export type CollectionBuilder = "admin-types" | "common" | "annotation";
210
+ /**
211
+ * The package specifiers the generated files name, spelled once.
212
+ *
213
+ * Written as constants rather than inline in the import templates below because
214
+ * `scripts/headless-guard/check-types.mjs` scans core sources for `from
215
+ * "@rebasepro/admin-types"` and cannot tell a real import from one this module
216
+ * *writes*. It is right to be that blunt — the guard's whole value is that it
217
+ * cannot be reasoned around — so the string simply never appears in that shape
218
+ * here. Inlining them back into the templates re-breaks `check:types-headless`.
219
+ */
220
+ export declare const ADMIN_TYPES_PACKAGE = "@rebasepro/admin-types";
221
+ export declare const COMMON_PACKAGE = "@rebasepro/common";
222
+ export declare const TYPES_PACKAGE = "@rebasepro/types";
180
223
  export interface GenerationContext {
181
224
  metadata?: SchemaMetadata;
182
225
  classifications?: Map<string, TableClassification>;
183
226
  checkFacts?: CheckFactsByTable;
227
+ /**
228
+ * Defaults to `admin-types`, which is what the generator has always emitted.
229
+ * The CLI never relies on the default — `introspect-db.ts` detects the flavour
230
+ * from the target project and passes it. See `detectCollectionBuilder`.
231
+ */
232
+ builder?: CollectionBuilder;
184
233
  }
185
234
  /**
186
235
  * Generate the full TypeScript file content for a single collection.
@@ -0,0 +1,21 @@
1
+ import { type CollectionBuilder } from "./introspect-db-logic";
2
+ /**
3
+ * Which `defineCollection` to generate against, for collections written to `outDir`.
4
+ *
5
+ * **The detection is the package manifests above the output directory**, unioned from
6
+ * `outDir` up to and including the project root — the first ancestor holding a
7
+ * `rebase.json`, or `MAX_LEVELS` up if there is none.
8
+ *
9
+ * That is the rule Node itself applies: a file in `config/collections` resolves a bare
10
+ * specifier through `config/node_modules`, then `<project>/node_modules`, and so on up.
11
+ * Reading the manifests along that same path answers the only question that matters —
12
+ * *will this import resolve in the project I am writing into* — from the state on disk
13
+ * at the moment of generation. The alternatives are all proxies for it: `rebase.json`'s
14
+ * `apps` block says a CMS scaffold declared an admin app, and a `frontend/` directory
15
+ * says one was scaffolded, but neither is what the compiler consults, and either can be
16
+ * true of a project whose `config` package does not depend on `@rebasepro/admin-types`.
17
+ *
18
+ * Ambiguity resolves towards the admin panel: a project that declares both packages has
19
+ * a panel, and `@rebasepro/admin-types` is the flavour that keeps the `admin` block.
20
+ */
21
+ export declare function detectCollectionBuilder(outDir: string): CollectionBuilder;
@@ -154,6 +154,55 @@ export declare const fuzzyColumnDefinition: (spec: SearchColumnSpec) => string |
154
154
  * concurrent form instead; see `ensureSearchColumns`.
155
155
  */
156
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[];
157
206
  /**
158
207
  * The index names the spec creates.
159
208
  *
@@ -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<{
@@ -14,9 +14,14 @@ import { REBASE_USER_ROLE } from "@rebasepro/common";
14
14
  * are validation/side-effects, not a security boundary.
15
15
  *
16
16
  * - **Server context** — the base (owner) connection: auth flows, migrations,
17
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
18
- * owner it bypasses RLS. This is the trusted plane, equivalent to
19
- * 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.
20
25
  *
21
26
  * This module provides the three pieces:
22
27
  *
@@ -190,6 +190,15 @@ export declare class FetchService {
190
190
  */
191
191
  searchRows<M extends Record<string, unknown>>(collectionPath: string, searchString: string, options?: {
192
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;
193
202
  orderBy?: string;
194
203
  order?: "desc" | "asc";
195
204
  limit?: number;