@rebasepro/server-postgres 0.23.0 → 0.24.1-canary.g56af4a4

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 (180) hide show
  1. package/dist/{BranchService-DGPL6G_C.js → BranchService-Bsn2wfaj.js} +36 -18
  2. package/dist/BranchService-Bsn2wfaj.js.map +1 -0
  3. package/dist/PostgresBackendDriver.d.ts +166 -13
  4. package/dist/auth/ensure-tables.d.ts +12 -0
  5. package/dist/auth/services.d.ts +78 -14
  6. package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
  7. package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
  8. package/dist/backup/backup-cli.d.ts +14 -0
  9. package/dist/backup/backup-service.d.ts +2 -2
  10. package/dist/backup/pg-binary.d.ts +5 -0
  11. package/dist/{backup-cli-24v4OlSp.js → backup-cli-aUfRv16h.js} +37 -29
  12. package/dist/backup-cli-aUfRv16h.js.map +1 -0
  13. package/dist/{backup-service-HQ9GC4tN.js → backup-service-BTBB7RXn.js} +60 -28
  14. package/dist/backup-service-BTBB7RXn.js.map +1 -0
  15. package/dist/boot-ddl-DVpsFN6r.js +105 -0
  16. package/dist/boot-ddl-DVpsFN6r.js.map +1 -0
  17. package/dist/cli-errors-CaI4ntyD.js +1919 -0
  18. package/dist/cli-errors-CaI4ntyD.js.map +1 -0
  19. package/dist/cli-errors.d.ts +93 -5
  20. package/dist/{cli-flags-BglvpjHv.js → cli-flags-B-Dawocl.js} +5 -2
  21. package/dist/cli-flags-B-Dawocl.js.map +1 -0
  22. package/dist/cli-helpers.d.ts +52 -12
  23. package/dist/cli-output.d.ts +16 -28
  24. package/dist/cli-push.d.ts +87 -0
  25. package/dist/cli.js +625 -190
  26. package/dist/cli.js.map +1 -1
  27. package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-drZIsSNf.js} +42 -26
  28. package/dist/column-plan-helpers-drZIsSNf.js.map +1 -0
  29. package/dist/{databasePoolManager-Bj5FbeAs.js → connection-BPoNILnD.js} +19 -110
  30. package/dist/connection-BPoNILnD.js.map +1 -0
  31. package/dist/connection.d.ts +9 -1
  32. package/dist/data-transformer.d.ts +0 -8
  33. package/dist/databasePoolManager-BkEy2w18.js +119 -0
  34. package/dist/databasePoolManager-BkEy2w18.js.map +1 -0
  35. package/dist/deferred-index-build-DP1_SNFo.js +1051 -0
  36. package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
  37. package/dist/{doctor-Cb2thZ8s.js → doctor-C4WiftHm.js} +279 -87
  38. package/dist/doctor-C4WiftHm.js.map +1 -0
  39. package/dist/ensure-collection-policies-CXpbeNag.js +541 -0
  40. package/dist/ensure-collection-policies-CXpbeNag.js.map +1 -0
  41. package/dist/ensure-collection-tables-BhsU6qUE.js +532 -0
  42. package/dist/ensure-collection-tables-BhsU6qUE.js.map +1 -0
  43. package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-CHsgyLqg.js} +178 -75
  44. package/dist/ensure-tables-CHsgyLqg.js.map +1 -0
  45. package/dist/enum-labels-kept-D8fI1MoX.js +56 -0
  46. package/dist/enum-labels-kept-D8fI1MoX.js.map +1 -0
  47. package/dist/generate-drizzle-schema-Bz52jH2h.js +57 -0
  48. package/dist/generate-drizzle-schema-Bz52jH2h.js.map +1 -0
  49. package/dist/{generate-drizzle-schema-logic-BfzK7UQd.js → generate-drizzle-schema-logic-B74WN37S.js} +28 -10
  50. package/dist/generate-drizzle-schema-logic-B74WN37S.js.map +1 -0
  51. package/dist/generate-files-CdEckX6c.js +199 -0
  52. package/dist/generate-files-CdEckX6c.js.map +1 -0
  53. package/dist/generate-postgres-ddl-logic-B64O2oZF.js +152 -0
  54. package/dist/generate-postgres-ddl-logic-B64O2oZF.js.map +1 -0
  55. package/dist/generated-sql.d.ts +34 -0
  56. package/dist/history/HistoryService.d.ts +38 -6
  57. package/dist/history/ensure-history-table.d.ts +42 -4
  58. package/dist/index.es.js +5557 -7612
  59. package/dist/index.es.js.map +1 -1
  60. package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-C-Di8dA9.js} +562 -350
  61. package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
  62. package/dist/introspect-db-queries-D6kAqReI.js +339 -0
  63. package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
  64. package/dist/pg-error-utils-vJezlXdI.js +388 -0
  65. package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
  66. package/dist/{plan-schema-C0fxM8dY.js → plan-schema-B1YF_tYq.js} +278 -533
  67. package/dist/plan-schema-B1YF_tYq.js.map +1 -0
  68. package/dist/{policy-drift-DljYdrpW.js → policy-drift-Dm8i-s1J.js} +24 -7
  69. package/dist/policy-drift-Dm8i-s1J.js.map +1 -0
  70. package/dist/push-index-prebuild-Db4AQF4i.js +45 -0
  71. package/dist/push-index-prebuild-Db4AQF4i.js.map +1 -0
  72. package/dist/push-legacy-renames-BK6ilQq2.js +34 -0
  73. package/dist/push-legacy-renames-BK6ilQq2.js.map +1 -0
  74. package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-CtC3y9Ol.js} +17 -151
  75. package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
  76. package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1046
  77. package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
  78. package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-DvzqlaL1.js} +137 -17
  79. package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
  80. package/dist/schema/auth-schema.d.ts +172 -2
  81. package/dist/schema/boot-ddl.d.ts +47 -0
  82. package/dist/schema/classify-change.d.ts +29 -1
  83. package/dist/schema/collection-index.d.ts +45 -0
  84. package/dist/schema/column-plan-helpers.d.ts +38 -21
  85. package/dist/schema/deferred-index-build.d.ts +131 -0
  86. package/dist/schema/destructive-sql.d.ts +27 -1
  87. package/dist/schema/doctor-cli.js +110 -6
  88. package/dist/schema/doctor-cli.js.map +1 -1
  89. package/dist/schema/doctor-search-scale.d.ts +45 -0
  90. package/dist/schema/doctor.d.ts +53 -1
  91. package/dist/schema/ensure-collection-policies.d.ts +95 -35
  92. package/dist/schema/ensure-collection-tables.d.ts +56 -3
  93. package/dist/schema/enum-labels-kept.d.ts +26 -0
  94. package/dist/schema/generate-drizzle-schema.js +1 -1
  95. package/dist/schema/generate-files.d.ts +44 -0
  96. package/dist/schema/generate-next-step.d.ts +0 -13
  97. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  98. package/dist/schema/generate-postgres-ddl.js +10 -81
  99. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  100. package/dist/schema/generate-schema-commit.d.ts +12 -0
  101. package/dist/schema/introspect-db-logic.d.ts +43 -0
  102. package/dist/schema/introspect-db-queries.d.ts +12 -2
  103. package/dist/schema/introspect-db-search.d.ts +22 -0
  104. package/dist/schema/introspect-db-storage.d.ts +83 -0
  105. package/dist/schema/introspect-db-structure.d.ts +10 -0
  106. package/dist/schema/introspect-db.js +17 -6
  107. package/dist/schema/introspect-db.js.map +1 -1
  108. package/dist/schema/migration-post-process.d.ts +34 -0
  109. package/dist/schema/plan/diff-plan.d.ts +127 -1
  110. package/dist/schema/plan/plan-schema.d.ts +16 -8
  111. package/dist/schema/plan/render-ddl.d.ts +6 -0
  112. package/dist/schema/plan/types.d.ts +45 -11
  113. package/dist/schema/push-index-prebuild.d.ts +25 -0
  114. package/dist/schema/push-legacy-renames.d.ts +20 -0
  115. package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
  116. package/dist/search-column-BM-GV6vH.js +442 -0
  117. package/dist/search-column-BM-GV6vH.js.map +1 -0
  118. package/dist/security/policy-drift.d.ts +1 -1
  119. package/dist/security/rls-enforcement.d.ts +39 -4
  120. package/dist/services/BranchService.d.ts +22 -1
  121. package/dist/services/FetchService.d.ts +63 -12
  122. package/dist/services/PersistService.d.ts +15 -0
  123. package/dist/services/RelationService.d.ts +106 -3
  124. package/dist/services/cdc/CdcListener.d.ts +26 -10
  125. package/dist/services/cdc/identity-columns.d.ts +19 -0
  126. package/dist/services/cdc/trigger-cdc.d.ts +180 -18
  127. package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
  128. package/dist/services/channel-history.d.ts +32 -5
  129. package/dist/services/collection-helpers.d.ts +26 -1
  130. package/dist/services/field-viewer.d.ts +15 -1
  131. package/dist/services/pg-notify-listener.d.ts +95 -6
  132. package/dist/services/realtimeService.d.ts +467 -53
  133. package/dist/services/row-pipeline.d.ts +16 -9
  134. package/dist/services/socket-liveness.d.ts +85 -0
  135. package/dist/services/soft-delete.d.ts +12 -0
  136. package/dist/services/sql-script.d.ts +275 -0
  137. package/dist/services/vector-iterative-scan.d.ts +40 -0
  138. package/dist/services/write-depth.d.ts +22 -0
  139. package/dist/services/write-transaction-scope.d.ts +15 -3
  140. package/dist/socket-liveness-D3UD5_BW.js +7570 -0
  141. package/dist/socket-liveness-D3UD5_BW.js.map +1 -0
  142. package/dist/socket-opt-ins.d.ts +64 -0
  143. package/dist/source-DBDbBTL4.js +811 -0
  144. package/dist/source-DBDbBTL4.js.map +1 -0
  145. package/dist/tsx-loader.d.ts +14 -0
  146. package/dist/utils/drizzle-conditions.d.ts +63 -3
  147. package/dist/utils/error-cause.d.ts +7 -0
  148. package/dist/utils/pg-error-utils.d.ts +2 -4
  149. package/dist/utils/result-rows.d.ts +10 -0
  150. package/dist/websocket.d.ts +63 -18
  151. package/package.json +7 -7
  152. package/dist/BranchService-DGPL6G_C.js.map +0 -1
  153. package/dist/backup-cli-24v4OlSp.js.map +0 -1
  154. package/dist/backup-service-HQ9GC4tN.js.map +0 -1
  155. package/dist/cli-errors-B8qHg02P.js +0 -798
  156. package/dist/cli-errors-B8qHg02P.js.map +0 -1
  157. package/dist/cli-flags-BglvpjHv.js.map +0 -1
  158. package/dist/cli-output-CNdMql-L.js +0 -47
  159. package/dist/cli-output-CNdMql-L.js.map +0 -1
  160. package/dist/collection-index-DdnsxiJ_.js +0 -286
  161. package/dist/collection-index-DdnsxiJ_.js.map +0 -1
  162. package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
  163. package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
  164. package/dist/doctor-Cb2thZ8s.js.map +0 -1
  165. package/dist/ensure-collection-policies-RHUcEp4v.js +0 -105
  166. package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
  167. package/dist/ensure-collection-tables-BEEjn5cn.js +0 -857
  168. package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
  169. package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
  170. package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
  171. package/dist/generate-drizzle-schema-yzY_BLhr.js +0 -123
  172. package/dist/generate-drizzle-schema-yzY_BLhr.js.map +0 -1
  173. package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
  174. package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
  175. package/dist/plan-schema-C0fxM8dY.js.map +0 -1
  176. package/dist/policy-drift-DljYdrpW.js.map +0 -1
  177. package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
  178. package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
  179. package/dist/source-Br7L7GOI.js +0 -410
  180. package/dist/source-Br7L7GOI.js.map +0 -1
@@ -1,317 +1,9 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
- import { firstFreeKey, toWireKey } from "@rebasepro/utils";
4
- //#region src/schema/introspect-db-queries.ts
5
- /**
6
- * A Postgres identifier, quoted so that whatever is inside it stays inside it.
7
- *
8
- * Introspection reads table and schema names out of `pg_class` and puts them
9
- * straight back into SQL, and wrapping a name in double quotes is not the same
10
- * as escaping it: a table named `x" ; DROP TABLE users; --` closes the quote
11
- * and then the statement. Postgres's own escape for a quote inside a quoted
12
- * identifier is to double it, which is what this does.
13
- *
14
- * "The catalogue is trusted" is true of your own database, and is exactly the
15
- * assumption this tool cannot make. Introspection exists to be pointed at
16
- * databases somebody else built, and it runs with the privileges of whoever
17
- * ran it — which is usually a superuser, because that is who has a connection
18
- * string to a database they are adopting.
19
- */
20
- function quoteIdentifier(name) {
21
- return `"${name.replace(/"/g, "\"\"")}"`;
22
- }
23
- /** `"schema"."table"`, both halves quoted. */
24
- function quoteRelation(schema, table) {
25
- return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
26
- }
27
- /**
28
- * Can this table's name be the name of the file describing it?
29
- *
30
- * Introspection writes `<collectionsDir>/<tableName>.ts`, and the table name
31
- * came from a database this tool did not create. Postgres will hold a table
32
- * called `../../../etc/cron.d/x`, and `path.join` resolves it — so without this
33
- * the generator writes a file wherever the name points, as whoever ran the
34
- * command.
35
- *
36
- * Deliberately narrow, and deliberately not a blocklist of `..` and `/`: the
37
- * question is "is this a plain file name?", and every answer that is not
38
- * obviously yes is a table better written by hand. NUL and the Windows
39
- * separator are in scope because a collections directory is checked out on
40
- * whatever machine the developer has.
41
- */
42
- function isUsableFileName(name) {
43
- if (name.length === 0 || name.length > 200) return false;
44
- if (name === "." || name === "..") return false;
45
- if (/[/\\:\u0000-\u001f]/.test(name)) return false;
46
- return !name.startsWith(".") && !name.endsWith(".");
47
- }
48
- /**
49
- * Base tables, excluding partitions.
50
- *
51
- * `relispartition` is the reason this reads `pg_class` rather than
52
- * `information_schema.tables`, which reports every partition as a base table of
53
- * its own. Pagila partitions `payment` by month; introspected through
54
- * information_schema it yields `payment` plus 26 near-identical
55
- * `payment_p2022_*` collections, each with its own nav entry.
56
- *
57
- * `relkind = 'p'` (the partitioned parent) is included and `'r'` partitions are
58
- * dropped, which is the right way round: the parent is the queryable table.
59
- */
60
- var TABLES_QUERY = `
61
- SELECT c.relname AS table_name,
62
- c.relkind = 'p' AS is_partitioned
63
- FROM pg_class c
64
- JOIN pg_namespace n ON n.oid = c.relnamespace
65
- WHERE n.nspname = $1
66
- AND c.relkind IN ('r', 'p')
67
- AND NOT c.relispartition
68
- AND c.relname NOT LIKE 'drizzle_%'
69
- AND c.relname NOT LIKE 'rebase_%'
70
- ORDER BY c.relname
71
- `;
72
- /**
73
- * Columns, with the catalog facts that say a column is not the user's to edit.
74
- *
75
- * `is_generated`/`is_identity` are read for that reason: a generated column
76
- * rejects any write, so a form that offers it is offering a field that always
77
- * errors. `character_maximum_length` is the declared `varchar(n)` bound —
78
- * already enforced by the database, and free to surface as validation.
79
- */
80
- var COLUMNS_QUERY = `
81
- SELECT
82
- c.table_name,
83
- c.column_name,
84
- c.data_type,
85
- c.udt_name,
86
- c.is_nullable,
87
- c.column_default,
88
- c.ordinal_position,
89
- c.is_generated,
90
- c.is_identity,
91
- c.identity_generation,
92
- c.character_maximum_length,
93
- c.numeric_precision,
94
- c.numeric_scale,
95
- (SELECT a.atttypmod FROM pg_attribute a
96
- JOIN pg_class pc ON a.attrelid = pc.oid
97
- WHERE pc.relname = c.table_name
98
- AND a.attname = c.column_name
99
- AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
100
- FROM information_schema.columns c
101
- WHERE c.table_schema = $1
102
- ORDER BY c.table_name, c.ordinal_position
103
- `;
104
- var ENUMS_QUERY = `
105
- SELECT t.typname AS enum_name,
106
- e.enumlabel AS enum_value,
107
- e.enumsortorder AS sort_order
108
- FROM pg_type t
109
- JOIN pg_enum e ON t.oid = e.enumtypid
110
- JOIN pg_namespace n ON t.typnamespace = n.oid
111
- WHERE n.nspname = $1
112
- ORDER BY t.typname, e.enumsortorder
113
- `;
114
- /**
115
- * Primary key columns, in key order.
116
- *
117
- * `ORDER BY k.ord` matters for composite keys: the position of a column inside
118
- * the key is what tells a two-column PK made of two foreign keys apart from an
119
- * ordinary composite key, and `= ANY(i.indkey)` (the shape this replaced)
120
- * returns catalog order instead.
121
- */
122
- var PRIMARY_KEYS_QUERY = `
123
- SELECT t.relname AS table_name,
124
- a.attname AS column_name
125
- FROM pg_index i
126
- JOIN pg_class t ON t.oid = i.indrelid
127
- JOIN pg_namespace n ON n.oid = t.relnamespace
128
- CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
129
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
130
- WHERE i.indisprimary AND n.nspname = $1
131
- ORDER BY t.relname, k.ord
132
- `;
133
- /**
134
- * Foreign keys, one row per referencing column, with the delete rule.
135
- *
136
- * Read from `pg_constraint` rather than through
137
- * `information_schema.constraint_column_usage`, which does not preserve the
138
- * pairing on a composite foreign key: it reports the cross product of the
139
- * constraint's columns, so a two-column FK comes back as four rows, two of them
140
- * pairing the wrong columns. `unnest(...) WITH ORDINALITY` on both sides joined
141
- * on the ordinal keeps each referencing column with the column it references.
142
- *
143
- * `confdeltype` is carried because `ON DELETE CASCADE` is the schema author
144
- * saying, in the database, that the child cannot outlive the parent — the one
145
- * unambiguous declaration of ownership a schema contains.
146
- *
147
- * Constraints declared on a *partition* are attributed to the partition root,
148
- * because that is the table this run generates a collection for. Pagila declares
149
- * `payment`'s three foreign keys on each monthly partition and none on the
150
- * parent, so reading `pg_constraint` at face value gives the `payment`
151
- * collection no relations at all. `pick = 1` then keeps one row per logical key,
152
- * preferring the root's own constraint when it has one, so a schema that
153
- * declares the key on the parent *and* inherits it onto 26 partitions does not
154
- * yield 27 copies of the same relation.
155
- */
156
- var FOREIGN_KEYS_QUERY = `
157
- WITH fk AS (
158
- SELECT con.oid,
159
- con.conname,
160
- con.conrelid,
161
- con.confrelid,
162
- con.conkey,
163
- con.confkey,
164
- con.confdeltype,
165
- COALESCE(pg_partition_root(con.conrelid), con.conrelid) AS root_oid,
166
- row_number() OVER (
167
- PARTITION BY COALESCE(pg_partition_root(con.conrelid), con.conrelid),
168
- con.confrelid, con.conkey, con.confkey
169
- ORDER BY (con.conrelid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)) DESC,
170
- con.oid
171
- ) AS pick
172
- FROM pg_constraint con
173
- JOIN pg_namespace n ON n.oid = con.connamespace
174
- WHERE con.contype = 'f' AND n.nspname = $1
175
- )
176
- SELECT root.relname AS table_name,
177
- sa.attname AS column_name,
178
- tgt.relname AS foreign_table_name,
179
- ta.attname AS foreign_column_name,
180
- fk.conname AS constraint_name,
181
- s.ord::int AS ordinal,
182
- CASE fk.confdeltype
183
- WHEN 'a' THEN 'NO ACTION'
184
- WHEN 'r' THEN 'RESTRICT'
185
- WHEN 'c' THEN 'CASCADE'
186
- WHEN 'n' THEN 'SET NULL'
187
- WHEN 'd' THEN 'SET DEFAULT'
188
- END AS delete_rule
189
- FROM fk
190
- JOIN pg_class root ON root.oid = fk.root_oid
191
- JOIN pg_class tgt ON tgt.oid = fk.confrelid
192
- CROSS JOIN LATERAL unnest(fk.conkey) WITH ORDINALITY AS s(attnum, ord)
193
- JOIN pg_attribute sa ON sa.attrelid = fk.conrelid AND sa.attnum = s.attnum
194
- CROSS JOIN LATERAL unnest(fk.confkey) WITH ORDINALITY AS f(attnum, ord)
195
- JOIN pg_attribute ta ON ta.attrelid = fk.confrelid AND ta.attnum = f.attnum
196
- WHERE fk.pick = 1 AND s.ord = f.ord
197
- ORDER BY root.relname, fk.conname, s.ord
198
- `;
199
- /**
200
- * Unique constraints and unique indexes, as ordered column lists.
201
- *
202
- * Indexes rather than constraints alone, because `CREATE UNIQUE INDEX` and
203
- * `ADD CONSTRAINT ... UNIQUE` produce the same guarantee and schemas use both.
204
- * Partial indexes (`indpred IS NOT NULL`) are excluded: they promise uniqueness
205
- * only over the rows matching their predicate, which is not the promise a
206
- * `validation: { unique: true }` field makes.
207
- */
208
- var UNIQUE_CONSTRAINTS_QUERY = `
209
- SELECT t.relname AS table_name,
210
- ic.relname AS constraint_name,
211
- -- ::text, because the driver has no parser for an array of \`name\`
212
- -- and hands back the raw \`{a,b}\` literal as a string. Every consumer
213
- -- here indexes and compares it as an array, and a string of that
214
- -- shape fails those silently rather than loudly.
215
- array_agg(a.attname::text ORDER BY k.ord) AS column_names
216
- FROM pg_index i
217
- JOIN pg_class t ON t.oid = i.indrelid
218
- JOIN pg_class ic ON ic.oid = i.indexrelid
219
- JOIN pg_namespace n ON n.oid = t.relnamespace
220
- CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
221
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
222
- WHERE i.indisunique
223
- AND NOT i.indisprimary
224
- AND i.indpred IS NULL
225
- AND n.nspname = $1
226
- AND NOT t.relispartition
227
- GROUP BY t.relname, ic.relname
228
- ORDER BY t.relname, ic.relname
229
- `;
230
- /**
231
- * CHECK constraints, as the source text Postgres reproduces them from.
232
- *
233
- * `pg_get_constraintdef` output is normalized by the server — the parser in
234
- * {@link ./introspect-db-constraints.ts} reads that normalized form, not
235
- * whatever the author typed.
236
- */
237
- var CHECK_CONSTRAINTS_QUERY = `
238
- SELECT DISTINCT
239
- root.relname AS table_name,
240
- con.conname AS constraint_name,
241
- pg_get_constraintdef(con.oid) AS definition
242
- FROM pg_constraint con
243
- JOIN pg_namespace n ON n.oid = con.connamespace
244
- JOIN pg_class root ON root.oid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)
245
- WHERE con.contype = 'c'
246
- AND n.nspname = $1
247
- ORDER BY root.relname, con.conname
248
- `;
249
- /**
250
- * `COMMENT ON TABLE` / `COMMENT ON COLUMN`, which is documentation the author
251
- * already wrote and which nothing downstream has ever read.
252
- */
253
- var COMMENTS_QUERY = `
254
- SELECT t.relname AS table_name,
255
- a.attname AS column_name,
256
- d.description AS comment
257
- FROM pg_description d
258
- JOIN pg_class t ON t.oid = d.objoid
259
- JOIN pg_namespace n ON n.oid = t.relnamespace
260
- LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.objsubid
261
- WHERE n.nspname = $1
262
- AND t.relkind IN ('r', 'p')
263
- AND (d.objsubid = 0 OR a.attname IS NOT NULL)
264
- ORDER BY t.relname, d.objsubid
265
- `;
266
- /**
267
- * Counts a table's rows, stopping once the answer can only be "more than
268
- * `limit`".
269
- *
270
- * Introspection asks this to tell a small reference table from a table that
271
- * merely looks like one (see `classifyTables`). `reltuples` would answer for
272
- * free but lies on a table that has never been analyzed — which is every table
273
- * in a database restored from a dump, i.e. exactly the case introspection meets
274
- * — and reports -1 there rather than an error, so the lie is silent.
275
- *
276
- * The subquery's LIMIT caps the work at `limit + 1` rows however large the
277
- * table is, so this stays cheap on a table with a billion rows.
278
- */
279
- async function countRowsUpTo(client, schema, table, limit) {
280
- const { rows } = await client.query(`SELECT count(*)::text AS count FROM (SELECT 1 FROM ${quoteRelation(schema, table)} LIMIT ${limit + 1}) probe`);
281
- return Number(rows[0]?.count ?? 0);
282
- }
283
- /**
284
- * Reads everything the generator needs, in one pass.
285
- *
286
- * `rowCounts` is deliberately absent here: it costs a query per table and only
287
- * a handful of tables can possibly need it. The caller fills it in for the
288
- * candidates `lookupCandidates()` names.
289
- */
290
- async function readSchemaMetadata(client, schema) {
291
- const tables = await client.query(TABLES_QUERY, [schema]);
292
- const columns = await client.query(COLUMNS_QUERY, [schema]);
293
- const enumValues = await client.query(ENUMS_QUERY, [schema]);
294
- const pks = await client.query(PRIMARY_KEYS_QUERY, [schema]);
295
- const fks = await client.query(FOREIGN_KEYS_QUERY, [schema]);
296
- const uniques = await client.query(UNIQUE_CONSTRAINTS_QUERY, [schema]);
297
- const checks = await client.query(CHECK_CONSTRAINTS_QUERY, [schema]);
298
- const comments = await client.query(COMMENTS_QUERY, [schema]);
299
- const known = new Set(tables.rows.map((t) => t.table_name));
300
- const scoped = (rows) => rows.filter((r) => known.has(r.table_name));
301
- return {
302
- schema,
303
- tables: tables.rows,
304
- columns: scoped(columns.rows),
305
- enumValues: enumValues.rows,
306
- pks: scoped(pks.rows),
307
- fks: scoped(fks.rows).filter((fk) => known.has(fk.foreign_table_name)),
308
- uniques: scoped(uniques.rows),
309
- checks: scoped(checks.rows),
310
- comments: scoped(comments.rows),
311
- rowCounts: {}
312
- };
313
- }
314
- //#endregion
3
+ import { a as buildSearchColumnSpec, u as searchColumnStamps } from "./search-column-BM-GV6vH.js";
4
+ import { d as foreignKeyIndexSpec } from "./source-DBDbBTL4.js";
5
+ import { DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE } from "@rebasepro/types";
6
+ import { firstFreeKey, toPostgresIdentifier, toWireKey } from "@rebasepro/utils";
315
7
  //#region src/schema/introspect-db-naming.ts
316
8
  /**
317
9
  * Naming helpers shared by the introspection modules. These live apart from
@@ -495,6 +187,359 @@ function mapPgType(dataType) {
495
187
  if (dt === "uuid") return "string";
496
188
  return "string";
497
189
  }
190
+ //#endregion
191
+ //#region src/schema/introspect-db-storage.ts
192
+ var literal = (value) => JSON.stringify(value);
193
+ /** `character varying` → `varchar`, the spellings the planner's `columnType` uses. */
194
+ var NUMBER_COLUMN_TYPE = {
195
+ "integer": "integer",
196
+ "bigint": "bigint",
197
+ "real": "real",
198
+ "double precision": "double precision"
199
+ };
200
+ /** A sequence default — `nextval('orders_id_seq'::regclass)`. */
201
+ var SEQUENCE_DEFAULT = /^nextval\('([^']+)'::regclass\)$/;
202
+ /**
203
+ * Whether a `nextval` default names the sequence `SERIAL` would have made for
204
+ * this column — the only one the planner can declare, because it declares the
205
+ * column as `SERIAL` and lets Postgres name the sequence.
206
+ */
207
+ var isConventionalSequence = (col, sequence) => {
208
+ return sequence.replace(/^.*\./, "").replace(/^"|"$/g, "") === serialSequenceName(col.table_name, col.column_name);
209
+ };
210
+ /**
211
+ * The name Postgres gives a `SERIAL` column's sequence — `ChooseRelationName`
212
+ * with the `seq` label: `<table>_<column>_seq`, and when that is over 63 bytes
213
+ * the longer of the two names is shortened, one character at a time, until it
214
+ * fits.
215
+ */
216
+ function serialSequenceName(table, column) {
217
+ const available = 58;
218
+ let tableChars = table.length;
219
+ let columnChars = column.length;
220
+ while (tableChars + columnChars > available) if (tableChars > columnChars) tableChars--;
221
+ else columnChars--;
222
+ return `${table.slice(0, tableChars)}_${column.slice(0, columnChars)}_seq`;
223
+ }
224
+ /**
225
+ * The `isId` (and `columnType`) a single-column primary key needs.
226
+ *
227
+ * `isId: "increment"` is an identity column, and only an INTEGER one. A serial
228
+ * key — an integer with a `nextval` default — is `SERIAL`/`BIGSERIAL`, declared
229
+ * through `columnType` beside `isId: "manual"`. A uuid key is `"uuid"` only when
230
+ * the database generates it; a key with no default is `"manual"`, which keeps
231
+ * the planner from inventing one.
232
+ */
233
+ function primaryKeyStorage(col, propType) {
234
+ const dataType = col.data_type.toLowerCase();
235
+ const fallback = col.column_default?.trim();
236
+ if (propType === "number") {
237
+ if (col.is_identity === "YES") {
238
+ if (dataType === "integer") return { keys: [`isId: ${literal("increment")}`] };
239
+ return {
240
+ keys: [`isId: ${literal("increment")}`],
241
+ note: `"${col.column_name}" is a ${dataType} identity key; \`isId: "increment"\` is always INTEGER, so a push would plan a narrowing type change (and ask before applying it).`
242
+ };
243
+ }
244
+ const sequence = fallback ? SEQUENCE_DEFAULT.exec(fallback) : null;
245
+ if (sequence) {
246
+ const serial = dataType === "bigint" ? "bigserial" : dataType === "integer" ? "serial" : void 0;
247
+ if (serial && isConventionalSequence(col, sequence[1])) return { keys: [`isId: ${literal("manual")}`, `columnType: ${literal(serial)}`] };
248
+ return {
249
+ keys: [`isId: ${literal("manual")}`, ...numberColumnType(dataType)],
250
+ note: `"${col.column_name}" takes its value from the sequence ${sequence[1]}, which no property can declare; a push would plan dropping that default.`
251
+ };
252
+ }
253
+ if (fallback) return { keys: [`isId: ${literal(`sql\`${fallback}\``)}`, ...numberColumnType(dataType)] };
254
+ return { keys: [`isId: ${literal("manual")}`, ...numberColumnType(dataType)] };
255
+ }
256
+ if (dataType === "uuid") {
257
+ if (fallback === "gen_random_uuid()") return { keys: [`isId: ${literal("uuid")}`] };
258
+ if (fallback) return { keys: [`isId: ${literal(`sql\`${fallback}\``)}`, `columnType: ${literal("uuid")}`] };
259
+ return { keys: [`isId: ${literal("manual")}`, `columnType: ${literal("uuid")}`] };
260
+ }
261
+ const strategy = fallback ? `sql\`${fallback}\`` : "manual";
262
+ const stored = stringStorage(col);
263
+ return {
264
+ keys: [`isId: ${literal(strategy)}`, ...stored.keys],
265
+ note: stored.note
266
+ };
267
+ }
268
+ /** `columnType` for a number column the planner would otherwise make NUMERIC. */
269
+ function numberColumnType(dataType) {
270
+ const mapped = NUMBER_COLUMN_TYPE[dataType];
271
+ return mapped ? [`columnType: ${literal(mapped)}`] : [];
272
+ }
273
+ /** The keys a `string` column needs; the bound itself is emitted as `validation.max`. */
274
+ function stringStorage(col) {
275
+ switch (col.data_type.toLowerCase()) {
276
+ case "text": return { keys: [] };
277
+ case "character varying":
278
+ if (typeof col.character_maximum_length === "number" && col.character_maximum_length > 0) return { keys: [`columnType: ${literal("varchar")}`] };
279
+ return {
280
+ keys: [],
281
+ note: `"${col.column_name}" is a varchar with no length, declared as text: a push would plan converting it to text, which keeps every value.`
282
+ };
283
+ case "character": return { keys: [`columnType: ${literal("char")}`] };
284
+ case "uuid": return { keys: [`columnType: ${literal("uuid")}`] };
285
+ default: return {
286
+ keys: [],
287
+ note: `"${col.column_name}" is ${col.data_type === "USER-DEFINED" ? col.udt_name : col.data_type}, which no property type stores; it is read as text, and a push would plan converting the column to text.`
288
+ };
289
+ }
290
+ }
291
+ /**
292
+ * The keys a non-key column needs, for the property type introspection chose.
293
+ *
294
+ * `enumTypeName` is the type the planner would name for this column
295
+ * (`<table>_<column>`, cut to 63 bytes); an enum column whose type is called
296
+ * anything else cannot be declared, and the note says how to rename it.
297
+ */
298
+ function columnStorage(col, propType, context) {
299
+ const dataType = col.data_type.toLowerCase();
300
+ if (context.isEnumColumn) {
301
+ if (col.udt_name === context.enumTypeName) return { keys: [] };
302
+ return {
303
+ keys: [],
304
+ note: context.enumTypeShared ? `"${col.column_name}" uses the enum type "${col.udt_name}", which other columns share; Rebase gives each column its own type (<table>_<column>), so a push would plan "${context.enumTypeName}" and a conversion Postgres cannot cast.` : `"${col.column_name}" uses the enum type "${col.udt_name}", and Rebase names a column's enum type <table>_<column>. Rename it before pushing: ALTER TYPE "${col.udt_name}" RENAME TO "${context.enumTypeName}";`
305
+ };
306
+ }
307
+ switch (propType) {
308
+ case "number": {
309
+ if (dataType === "numeric") {
310
+ const keys = [];
311
+ if (typeof col.numeric_precision === "number") keys.push(`precision: ${col.numeric_precision}`);
312
+ if (typeof col.numeric_precision === "number" && typeof col.numeric_scale === "number") keys.push(`scale: ${col.numeric_scale}`);
313
+ return { keys };
314
+ }
315
+ if (dataType === "smallint") return {
316
+ keys: [`columnType: ${literal("integer")}`],
317
+ note: `"${col.column_name}" is smallint, which no \`columnType\` names; it is declared integer, so a push widens it (every value survives).`
318
+ };
319
+ const mapped = numberColumnType(dataType);
320
+ if (mapped.length > 0) return { keys: mapped };
321
+ return {
322
+ keys: [],
323
+ note: `"${col.column_name}" is ${col.data_type}, which no \`columnType\` names; it is declared numeric, and a push would plan converting it.`
324
+ };
325
+ }
326
+ case "string": return stringStorage(col);
327
+ case "date": switch (dataType) {
328
+ case "timestamp with time zone": return { keys: [] };
329
+ case "date": return { keys: [`columnType: ${literal("date")}`] };
330
+ case "time without time zone": return { keys: [`columnType: ${literal("time")}`] };
331
+ default: return {
332
+ keys: [],
333
+ note: `"${col.column_name}" is ${col.data_type}; a date property stores timestamptz, date or time, so a push would plan converting it to timestamptz (and ask before applying it).`
334
+ };
335
+ }
336
+ case "map": return dataType === "json" ? { keys: [`columnType: ${literal("json")}`] } : { keys: [] };
337
+ default: return { keys: [] };
338
+ }
339
+ }
340
+ /**
341
+ * The `columnType` an array column needs, or a note when its element type is
342
+ * not one the planner stores natively.
343
+ *
344
+ * Only `text[]`, `integer[]`, `boolean[]` and `numeric[]` are. Reading
345
+ * `bigint[]` as `integer[]` — what this used to do — planned a narrowing.
346
+ */
347
+ function arrayStorage(col) {
348
+ switch (col.udt_name) {
349
+ case "_text": return {
350
+ columnType: "text[]",
351
+ innerType: "string"
352
+ };
353
+ case "_int4": return {
354
+ columnType: "integer[]",
355
+ innerType: "number"
356
+ };
357
+ case "_bool": return {
358
+ columnType: "boolean[]",
359
+ innerType: "boolean"
360
+ };
361
+ case "_numeric": return {
362
+ columnType: "numeric[]",
363
+ innerType: "number"
364
+ };
365
+ default: {
366
+ const element = col.udt_name.replace(/^_/, "");
367
+ return {
368
+ innerType: "string",
369
+ note: `"${col.column_name}" is an array of ${element}; arrays are stored as text[], integer[], boolean[] or numeric[] (anything else as jsonb), so a push would plan converting it.`
370
+ };
371
+ }
372
+ }
373
+ }
374
+ /**
375
+ * A column default as a `defaultValue`, when it is a literal a property can carry.
376
+ *
377
+ * `undefined` when there is no default to carry, or when the default is one the
378
+ * generator expresses another way (`now()` is `autoValue`, a key's default is
379
+ * its `isId`). `{ note }` when there is a default and no property can say it —
380
+ * a push would plan dropping it.
381
+ */
382
+ function literalDefaultOf(col, propType) {
383
+ const raw = col.column_default?.trim();
384
+ if (!raw) return void 0;
385
+ if (/^(now\(\)|CURRENT_TIMESTAMP)$/i.test(raw)) return void 0;
386
+ const quoted = /^'((?:[^']|'')*)'(?:::[\w\s."[\]]+)?$/.exec(raw);
387
+ const unquoted = quoted ? quoted[1].replace(/''/g, "'") : void 0;
388
+ switch (propType) {
389
+ case "string":
390
+ if (unquoted !== void 0) return { source: literal(unquoted) };
391
+ break;
392
+ case "number": {
393
+ const text = unquoted ?? raw.replace(/^\((.*)\)$/, "$1").replace(/::[\w\s]+$/, "");
394
+ if (/^-?\d+(\.\d+)?$/.test(text) && Number.isFinite(Number(text))) return { source: String(Number(text)) };
395
+ break;
396
+ }
397
+ case "boolean":
398
+ if (/^true$/i.test(raw)) return { source: "true" };
399
+ if (/^false$/i.test(raw)) return { source: "false" };
400
+ break;
401
+ case "map":
402
+ if (unquoted !== void 0) try {
403
+ const parsed = JSON.parse(unquoted);
404
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return { source: JSON.stringify(parsed) };
405
+ } catch {}
406
+ break;
407
+ default: break;
408
+ }
409
+ return { note: `"${col.column_name}" defaults to ${raw}, which no \`defaultValue\` can express; a push would plan dropping that default.` };
410
+ }
411
+ /** `ON DELETE` as the relation's `onDelete`, when it is not the default the planner would choose. */
412
+ function onDeleteOf(fk, required) {
413
+ const rule = fk.delete_rule?.toUpperCase();
414
+ if (!rule) return void 0;
415
+ if (rule === (required ? "RESTRICT" : "SET NULL")) return void 0;
416
+ return rule.toLowerCase();
417
+ }
418
+ //#endregion
419
+ //#region src/schema/introspect-db-search.ts
420
+ /**
421
+ * A collection's `search` block, read back out of the column it built.
422
+ *
423
+ * A Rebase search column is `tsvector GENERATED ALWAYS AS (…) STORED`, stamped
424
+ * with a fingerprint of the expression in its `COMMENT`. Introspection used to
425
+ * emit it as an ordinary read-only string property, which the planner makes a
426
+ * `text` column — so the first push after adopting a Rebase database planned
427
+ * `ALTER COLUMN "search_vector" TYPE text … DROP EXPRESSION`, and search was
428
+ * gone for whoever approved it.
429
+ *
430
+ * Reading the block back is a guess checked by the stamp: the deparsed
431
+ * expression names each field's column, weight and language, which is enough
432
+ * to write a candidate block; the candidate is then compiled by the very code
433
+ * that built the column, and kept only if its fingerprint is the one the column
434
+ * carries. A block that does not reproduce the stamp is not emitted at all —
435
+ * the note says so — because a wrong one would rebuild the column differently.
436
+ *
437
+ * `mode` and `fuzzyThreshold` act at query time and are stored nowhere, so they
438
+ * come back as their defaults.
439
+ */
440
+ /** One `setweight(to_tsvector('<lang>'::regconfig, <text>), '<W>'::"char")`, deparsed. */
441
+ var SEGMENT = /setweight\(to_tsvector\('((?:[^']|'')+)'::regconfig, ([\s\S]+?)\), '([A-D])'::"char"\)/g;
442
+ /** A property with the column's shape, for the candidate block to resolve against. */
443
+ function propertyFor(col) {
444
+ if (col.data_type === "text" || col.data_type === "character varying" || col.data_type === "character") return {
445
+ name: col.column_name,
446
+ type: "string",
447
+ columnName: col.column_name
448
+ };
449
+ if (col.udt_name === "_text") return {
450
+ name: col.column_name,
451
+ type: "array",
452
+ columnName: col.column_name,
453
+ of: {
454
+ name: col.column_name,
455
+ type: "string"
456
+ }
457
+ };
458
+ if (col.data_type === "jsonb") return {
459
+ name: col.column_name,
460
+ type: "map",
461
+ columnName: col.column_name,
462
+ keyValue: true
463
+ };
464
+ }
465
+ /**
466
+ * The search block a table's stamped generated columns were built from, or a
467
+ * note saying why it could not be read back.
468
+ */
469
+ function reconstructSearchBlock(input) {
470
+ const stamped = input.columns.filter((col) => col.is_generated === "ALWAYS" && (input.comments.get(col.column_name) ?? "").startsWith("rebase:search:v1:"));
471
+ const main = stamped.find((col) => col.udt_name === "tsvector");
472
+ if (!main) return { columns: /* @__PURE__ */ new Set() };
473
+ const fuzzy = stamped.find((col) => col.column_name === `${main.column_name}_text`);
474
+ const columns = new Set(stamped.map((col) => col.column_name));
475
+ const unreadable = (why) => ({
476
+ columns,
477
+ note: `"${main.column_name}" is a Rebase search column whose \`search\` block could not be read back (${why}). Declare the block again before pushing — without it a push plans dropping the column.`
478
+ });
479
+ const expression = main.generation_expression ?? "";
480
+ const byColumn = new Map(input.columns.map((col) => [col.column_name, col]));
481
+ const fields = [];
482
+ let language;
483
+ let unaccent = false;
484
+ for (const [, lang, inner, weight] of expression.matchAll(SEGMENT)) {
485
+ language ??= lang.replace(/''/g, "'");
486
+ if (lang.replace(/''/g, "'") !== language) return unreadable("its fields use different languages");
487
+ if (inner.includes("rebase_search_unaccent")) unaccent = true;
488
+ const column = [...inner.matchAll(/"?([A-Za-z_][A-Za-z0-9_$]*)"?/g)].map((m) => m[1]).find((name) => byColumn.has(name) && !columns.has(name));
489
+ if (!column) return unreadable("a field reads no column of this table");
490
+ const key = input.keyByColumn.get(column);
491
+ if (!key) return unreadable(`the column "${column}" is not a property`);
492
+ const jsonPath = [...inner.matchAll(/(?:->|#>)\s*'([^']+)'/g)].flatMap((m) => m[1].replace(/^\{|\}$/g, "").split(","));
493
+ fields.push({
494
+ path: [key, ...jsonPath].join("."),
495
+ weight
496
+ });
497
+ }
498
+ if (fields.length === 0 || !language) return unreadable("its expression is not one Rebase writes");
499
+ const search = {
500
+ ...main.column_name !== DEFAULT_SEARCH_COLUMN ? { column: main.column_name } : {},
501
+ ...language !== DEFAULT_SEARCH_LANGUAGE ? { language } : {},
502
+ ...unaccent ? { unaccent: true } : {},
503
+ ...fuzzy ? { fuzzy: true } : {},
504
+ fields: fields.map((f) => ({
505
+ path: f.path,
506
+ weight: f.weight
507
+ }))
508
+ };
509
+ const properties = {};
510
+ for (const [column, key] of input.keyByColumn) {
511
+ const col = byColumn.get(column);
512
+ const prop = col ? propertyFor(col) : void 0;
513
+ if (prop) properties[key] = prop;
514
+ }
515
+ const candidate = {
516
+ slug: input.table,
517
+ name: input.table,
518
+ table: input.table,
519
+ ...input.schema !== "public" ? { schema: input.schema } : {},
520
+ properties,
521
+ search
522
+ };
523
+ let fingerprints;
524
+ try {
525
+ const spec = buildSearchColumnSpec(candidate);
526
+ if (!spec) return unreadable("no block compiles from it");
527
+ fingerprints = new Map(searchColumnStamps(spec).map((stamp) => [stamp.column, stamp.fingerprint]));
528
+ } catch (err) {
529
+ return unreadable(err instanceof Error ? err.message : String(err));
530
+ }
531
+ for (const col of stamped) if (fingerprints.get(col.column_name) !== input.comments.get(col.column_name)) return unreadable("the block read from its expression does not reproduce the column's stamp");
532
+ return {
533
+ columns,
534
+ block: `\n search: {\n ${[
535
+ ...search.column ? [`column: ${JSON.stringify(search.column)}`] : [],
536
+ ...search.language ? [`language: ${JSON.stringify(search.language)}`] : [],
537
+ ...search.unaccent ? ["unaccent: true"] : [],
538
+ ...search.fuzzy ? ["fuzzy: true"] : [],
539
+ `fields: [\n${fields.map((f) => ` { path: ${JSON.stringify(f.path)}, weight: ${JSON.stringify(f.weight)} }`).join(",\n")}\n ]`
540
+ ].join(",\n ")}\n },`
541
+ };
542
+ }
498
543
  /**
499
544
  * A timestamp the database maintains: a temporal column defaulting to the
500
545
  * transaction clock.
@@ -869,6 +914,32 @@ function deriveKanbanProperty(facts) {
869
914
  return fact.column.column_name;
870
915
  }
871
916
  }
917
+ /** Names a string column goes by when it holds a board's card order (a fractional-indexing key). */
918
+ var ORDER_KEY_NAMES = /* @__PURE__ */ new Set([
919
+ "__order",
920
+ "order_key",
921
+ "sort_key",
922
+ "rank",
923
+ "lexorank",
924
+ "position_key"
925
+ ]);
926
+ /**
927
+ * The string column that can hold a board's card order, or nothing.
928
+ *
929
+ * A board without `orderProperty` cannot keep a card's position within a
930
+ * column, and the config validator says so on every generate — so a board is
931
+ * emitted only when the table already has somewhere to store the order. A
932
+ * legacy table almost never does; asking its owner to add a hidden column
933
+ * just to satisfy generated output was the wrong way round.
934
+ */
935
+ function deriveKanbanOrderProperty(facts) {
936
+ for (const fact of facts.values()) {
937
+ if (fact.isPk || fact.isFk || fact.isGenerated || fact.enumValues) continue;
938
+ if (fact.propType !== "string") continue;
939
+ if (!/^(text|character varying)$/.test(fact.column.data_type)) continue;
940
+ if (ORDER_KEY_NAMES.has(fact.column.column_name.toLowerCase())) return fact.column.column_name;
941
+ }
942
+ }
872
943
  /**
873
944
  * The column a list should be sorted by, newest first.
874
945
  *
@@ -908,6 +979,34 @@ function deriveListProperties(propertiesOrder, hidden = /* @__PURE__ */ new Set(
908
979
  * no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
909
980
  * and consumed directly by tests.
910
981
  */
982
+ /**
983
+ * Why a table keyed on several columns is not generated as a collection, or
984
+ * `undefined` when it is.
985
+ *
986
+ * Several `isId` properties are one composite key, and the planner builds that
987
+ * table under the same `PRIMARY KEY (a, b)` — so such a table round-trips when
988
+ * two things hold:
989
+ *
990
+ * - **Every key column can carry `isId`,** which exists on string and number
991
+ * properties only. Read from the column's SQL type, not a data sample: a
992
+ * `line_no` holding only 1s is not a boolean. A key with a column of another
993
+ * type — pagila's `payment` is keyed on a timestamp too — can be marked on
994
+ * some of its columns only, and those would be read as the whole key.
995
+ * - **Nothing points at it.** A foreign key into it, or a junction, is a link
996
+ * into a composite key, which the planner refuses: the relation has one
997
+ * column to point with.
998
+ */
999
+ function compositeKeySkipReason(meta, allFks) {
1000
+ if (meta.pks.length < 2) return void 0;
1001
+ const unmarkable = meta.pks.filter((pk) => {
1002
+ const column = meta.columns.find((c) => c.column_name === pk);
1003
+ const type = column ? mapPgType(column.data_type) : void 0;
1004
+ return type !== "string" && type !== "number";
1005
+ });
1006
+ if (unmarkable.length > 0) return `its key column${unmarkable.length > 1 ? "s" : ""} ${unmarkable.map((c) => `"${c}"`).join(", ")} cannot carry \`isId\`, which a string or number property takes`;
1007
+ const referencing = [...new Set(allFks.filter((fk) => fk.foreign_table_name === meta.name && fk.table_name !== meta.name).map((fk) => fk.table_name))];
1008
+ if (referencing.length > 0) return `${referencing.map((t) => `"${t}"`).join(", ")} point${referencing.length > 1 ? "" : "s"} at it, and a link cannot point at a composite key`;
1009
+ }
911
1010
  var IRREGULAR_SINGULARS = {
912
1011
  people: "person",
913
1012
  children: "child",
@@ -1265,6 +1364,8 @@ function withAdminOptions(extra, entries, emitAdmin = true) {
1265
1364
  if (extra.includes("admin: {")) return extra.replace("admin: {", `admin: {\n${block},`);
1266
1365
  return `${extra}\n admin: {\n${block}\n },`;
1267
1366
  }
1367
+ /** A SQL string literal. */
1368
+ var quoteSqlString = (value) => `'${value.replace(/'/g, "''")}'`;
1268
1369
  /** A TypeScript string literal, escaped. */
1269
1370
  function quote(value) {
1270
1371
  return JSON.stringify(value);
@@ -1369,6 +1470,9 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1369
1470
  };
1370
1471
  let propsOutput = "";
1371
1472
  let relationsOutput = "";
1473
+ /** What the planner will not reproduce exactly, column by column. */
1474
+ const fileNotes = [];
1475
+ const skippedTables = context.skippedTables ?? /* @__PURE__ */ new Set();
1372
1476
  const orderEntries = [];
1373
1477
  const propertyBlocks = /* @__PURE__ */ new Map();
1374
1478
  /**
@@ -1386,25 +1490,45 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1386
1490
  const hiddenFromCollection = /* @__PURE__ */ new Set();
1387
1491
  let columnIndex = 0;
1388
1492
  const isCompositePk = meta.pks.length > 1;
1493
+ /**
1494
+ * The property type a column is generated as, with what the data sample
1495
+ * adds to it. A column of a composite key keeps its SQL type's reading:
1496
+ * it carries `isId`, which only a string or number property takes, and a
1497
+ * key column holding only 1s and 0s is still a key, not a boolean.
1498
+ */
1499
+ const propertyTypeOf = (col) => {
1500
+ const isEnumColumn = col.data_type === "USER-DEFINED" && enumMap.get(col.udt_name) !== void 0;
1501
+ const propType = isEnumColumn ? "string" : col.udt_name === "vector" ? "vector" : mapPgType(col.data_type);
1502
+ if (isEnumColumn || !sampleData || sampleData.length === 0 || isCompositePk && meta.pks.includes(col.column_name)) return {
1503
+ propType,
1504
+ finalPropType: propType,
1505
+ inferenceExtra: ""
1506
+ };
1507
+ const values = sampleData.map((r) => r[col.column_name]);
1508
+ const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name), emitAdmin);
1509
+ return {
1510
+ propType,
1511
+ finalPropType: inferred.propType || propType,
1512
+ inferenceExtra: inferred.extra || ""
1513
+ };
1514
+ };
1515
+ const compositeKeyMarkable = isCompositePk && meta.pks.every((pk) => {
1516
+ const keyColumn = meta.columns.find((c) => c.column_name === pk);
1517
+ if (!keyColumn) return false;
1518
+ const { finalPropType } = propertyTypeOf(keyColumn);
1519
+ return finalPropType === "string" || finalPropType === "number";
1520
+ });
1389
1521
  for (const col of meta.columns) {
1522
+ if (col.is_generated === "ALWAYS" && (columnComments.get(col.column_name) ?? "").startsWith("rebase:search:v1:")) continue;
1390
1523
  if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
1391
1524
  const currentIndex = columnIndex++;
1392
1525
  const propertyKey = firstFreeKey([toWireKey(col.column_name), col.column_name], propertyBlocks);
1393
1526
  keyByColumn.set(col.column_name, propertyKey);
1394
1527
  const colEnumValues = enumMap.get(col.udt_name);
1395
1528
  const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== void 0;
1396
- const isVectorColumn = col.udt_name === "vector";
1397
- const propType = isEnumColumn ? "string" : isVectorColumn ? "vector" : mapPgType(col.data_type);
1398
1529
  let extra = "";
1399
1530
  const colNameLower = col.column_name.toLowerCase();
1400
- let finalPropType = propType;
1401
- let inferenceExtra = "";
1402
- if (!isEnumColumn && sampleData && sampleData.length > 0) {
1403
- const values = sampleData.map((r) => r[col.column_name]);
1404
- const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name), emitAdmin);
1405
- if (inferred.propType) finalPropType = inferred.propType;
1406
- if (inferred.extra) inferenceExtra = inferred.extra;
1407
- }
1531
+ const { propType, finalPropType, inferenceExtra } = propertyTypeOf(col);
1408
1532
  const columnChecks = tableChecks?.get(col.column_name);
1409
1533
  if (isEnumColumn && colEnumValues) {
1410
1534
  const enumEntries = colEnumValues.map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`).join(", ");
@@ -1412,6 +1536,13 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1412
1536
  } else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
1413
1537
  const enumEntries = columnChecks.enumValues.map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`).join(", ");
1414
1538
  extra += `\n enum: [${enumEntries}],`;
1539
+ const enumType = toPostgresIdentifier(`${tableName}_${col.column_name}`);
1540
+ const labels = columnChecks.enumValues.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
1541
+ const check = (context.metadata?.checks ?? []).find((c) => c.table_name === tableName && c.definition.includes(col.column_name));
1542
+ const table = `"${tableName}"`;
1543
+ const column = `"${col.column_name}"`;
1544
+ const restoreDefault = literalDefaultOf(col, "string");
1545
+ fileNotes.push(`"${col.column_name}" is text with CHECK (… IN …), declared here as an \`enum\`, which Rebase stores as the enum type "${enumType}". Convert the column before pushing: CREATE TYPE "${enumType}" AS ENUM (${labels}); ALTER TABLE ${table} ${check ? `DROP CONSTRAINT "${check.constraint_name}", ` : ""}ALTER COLUMN ${column} DROP DEFAULT, ALTER COLUMN ${column} TYPE "${enumType}" USING ${column}::"${enumType}"` + (restoreDefault && "source" in restoreDefault ? `, ALTER COLUMN ${column} SET DEFAULT ${quoteSqlString(JSON.parse(restoreDefault.source))}` : "") + "; — or delete the `enum` to keep the column as it is.");
1415
1546
  }
1416
1547
  if (finalPropType === "date") {
1417
1548
  if (colNameLower === "created_at" || colNameLower === "createdat") {
@@ -1428,16 +1559,10 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1428
1559
  }
1429
1560
  }
1430
1561
  if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
1431
- let innerType = "string";
1432
- let colType = "";
1433
- if (col.udt_name.startsWith("_")) {
1434
- innerType = mapPgType(col.udt_name.substring(1));
1435
- if (innerType === "string") colType = "text[]";
1436
- else if (innerType === "number") colType = col.udt_name === "_numeric" ? "numeric[]" : "integer[]";
1437
- else if (innerType === "boolean") colType = "boolean[]";
1438
- }
1439
- if (colType) extra += `\n columnType: ${quote(colType)},`;
1440
- extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(innerType)} },`;
1562
+ const stored = arrayStorage(col);
1563
+ if (stored.columnType) extra += `\n columnType: ${quote(stored.columnType)},`;
1564
+ if (stored.note) fileNotes.push(stored.note);
1565
+ extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(stored.innerType)} },`;
1441
1566
  } else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) extra += "\n keyValue: true,";
1442
1567
  if (finalPropType === "string" && !isEnumColumn && !inferenceExtra) {
1443
1568
  const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
@@ -1451,6 +1576,29 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1451
1576
  extra += inferenceExtra;
1452
1577
  if (!extra.endsWith(",")) extra += ",";
1453
1578
  }
1579
+ if (!meta.pks.includes(col.column_name) && !isReadOnlyColumn(col)) {
1580
+ const enumTypeName = toPostgresIdentifier(`${tableName}_${col.column_name}`);
1581
+ const enumTypeShared = isEnumColumn && (context.metadata?.columns ?? meta.columns).filter((other) => other.udt_name === col.udt_name && other.data_type === "USER-DEFINED").length > 1;
1582
+ const stored = finalPropType === "array" ? { keys: [] } : columnStorage(col, finalPropType, {
1583
+ isEnumColumn,
1584
+ enumTypeName,
1585
+ enumTypeShared
1586
+ });
1587
+ for (const key of stored.keys) {
1588
+ const name = key.slice(0, key.indexOf(":"));
1589
+ if (!hasGeneratedKey(extra, name) && !hasGeneratedKey(inferenceExtra, name)) extra += `\n ${key},`;
1590
+ }
1591
+ if (stored.note) fileNotes.push(stored.note);
1592
+ if (!hasGeneratedKey(extra, "autoValue") && !hasGeneratedKey(extra, "defaultValue")) {
1593
+ const fallback = literalDefaultOf(col, finalPropType);
1594
+ if (fallback && "source" in fallback) extra += `\n defaultValue: ${fallback.source},`;
1595
+ else if (fallback) {
1596
+ const expression = col.column_default?.trim();
1597
+ if (col.is_nullable === "NO" && expression) extra += `\n databaseDefault: ${JSON.stringify(`sql\`${expression}\``)},`;
1598
+ else fileNotes.push(fallback.note);
1599
+ }
1600
+ }
1601
+ }
1454
1602
  const declaredValidation = [];
1455
1603
  if (finalPropType === "string" && typeof col.character_maximum_length === "number" && col.character_maximum_length > 0 && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${col.character_maximum_length}`);
1456
1604
  if (columnChecks) {
@@ -1478,16 +1626,30 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1478
1626
  const columnComment = columnComments.get(col.column_name);
1479
1627
  if (columnComment) extra = `\n description: ${quote(columnComment)},${extra}`;
1480
1628
  if (meta.pks.includes(col.column_name)) {
1481
- if (isCompositePk) extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
1482
- else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) extra += "\n isId: \"increment\",";
1483
- else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) extra += "\n isId: \"uuid\",";
1484
- else if (!inferenceExtra.includes("isId:")) extra += "\n isId: \"uuid\", // Verify if this is a UUID or CUID";
1629
+ if (isCompositePk) {
1630
+ extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
1631
+ if (compositeKeyMarkable && !inferenceExtra.includes("isId:")) {
1632
+ const stored = primaryKeyStorage(col, finalPropType);
1633
+ for (const key of stored.keys) {
1634
+ const name = key.slice(0, key.indexOf(":"));
1635
+ if (!hasGeneratedKey(extra, name)) extra += `\n ${key},`;
1636
+ }
1637
+ if (stored.note) fileNotes.push(stored.note);
1638
+ }
1639
+ } else if (!inferenceExtra.includes("isId:")) {
1640
+ const stored = primaryKeyStorage(col, finalPropType);
1641
+ for (const key of stored.keys) {
1642
+ const name = key.slice(0, key.indexOf(":"));
1643
+ if (!hasGeneratedKey(extra, name)) extra += `\n ${key},`;
1644
+ }
1645
+ if (stored.note) fileNotes.push(stored.note);
1646
+ }
1485
1647
  }
1486
1648
  if (finalPropType === "vector") {
1487
1649
  const dims = col.atttypmod && col.atttypmod > 0 ? col.atttypmod : 1536;
1488
1650
  extra += `\n dimensions: ${dims},`;
1489
1651
  }
1490
- if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default && !isReadOnlyColumn(col)) if (extra.includes("validation: {")) extra = extra.replace("validation: {", "validation: {\n required: true,");
1652
+ if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !isReadOnlyColumn(col)) if (extra.includes("validation: {")) extra = extra.replace("validation: {", "validation: {\n required: true,");
1491
1653
  else extra += "\n validation: {\n required: true\n },";
1492
1654
  const humanName = humanize(col.column_name);
1493
1655
  orderEntries.push({
@@ -1510,6 +1672,7 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1510
1672
  }
1511
1673
  for (const fk of meta.fks) {
1512
1674
  const targetTableName = fk.foreign_table_name;
1675
+ if (skippedTables.has(targetTableName)) continue;
1513
1676
  if (!joinTables.has(targetTableName)) {
1514
1677
  const stripped = toWireKey(fk.column_name.replace(/_id$/, ""));
1515
1678
  const relName = firstFreeKey([
@@ -1530,27 +1693,50 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1530
1693
  });
1531
1694
  const targetCollectionCamel = importCollection(targetTableName);
1532
1695
  const relHumanName = humanize(relName);
1696
+ const required = meta.columns.find((c) => c.column_name === fk.column_name)?.is_nullable === "NO";
1697
+ const onDelete = onDeleteOf(fk, required);
1698
+ const requiredLine = required ? "\n validation: { required: true }," : "";
1699
+ const onDeleteLine = onDelete ? `,\n onDelete: ${quote(onDelete)}` : "";
1700
+ const derivedIndex = foreignKeyIndexSpec(context.metadata?.schema ?? "public", tableName, fk.column_name);
1701
+ const indexLine = context.metadata?.indexes !== void 0 && meta.pks[0] !== fk.column_name && !context.metadata.indexes.some((i) => i.table_name === tableName && i.index_name === derivedIndex.indexName) ? `,\n // The database has no index of Rebase's on ${commentText(fk.column_name)}. Delete this line to have one built.
1702
+ index: false` : "";
1533
1703
  propertyBlocks.set(relName, `
1534
1704
  ${propKey(relName)}: {
1535
1705
  name: ${quote(relHumanName)},
1536
- type: "relation",
1706
+ type: "relation",${requiredLine}
1537
1707
  // mapped from foreign key: ${commentText(fk.column_name)} -> ${commentText(targetTableName)}(${commentText(fk.foreign_column_name)})
1538
1708
  relation: {
1539
1709
  kind: "belongsTo",
1540
1710
  target: ${relationTarget(targetCollectionCamel)},
1541
- localKey: ${quote(fk.column_name)}
1711
+ localKey: ${quote(fk.column_name)}${onDeleteLine}${indexLine}
1542
1712
  }
1543
1713
  },`);
1544
1714
  }
1545
1715
  }
1546
- const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
1716
+ /**
1717
+ * A name for an entry in the `relations` array that no property and no
1718
+ * earlier entry has taken.
1719
+ *
1720
+ * Relations and properties share one namespace — a relation is found by
1721
+ * name — so a `belongsTo` property called `series` beside a many-to-many
1722
+ * also called `series` left one of them unreachable: the planner resolved
1723
+ * the property's name to the many-to-many, planned no `series_id` column,
1724
+ * and the first push dropped it.
1725
+ */
1726
+ const usedRelationNames = /* @__PURE__ */ new Set();
1727
+ const relationNameFor = (preferred, fallback) => {
1728
+ const name = firstFreeKey([preferred, fallback], { has: (key) => propertyBlocks.has(key) || usedRelationNames.has(key) });
1729
+ usedRelationNames.add(name);
1730
+ return name;
1731
+ };
1732
+ const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name) && !skippedTables.has(fk.table_name));
1547
1733
  for (const fk of inverseFks) {
1548
1734
  const sourceTableName = fk.table_name;
1549
1735
  const targetCollectionCamel = importCollection(sourceTableName);
1550
1736
  relationsOutput += `
1551
1737
  {
1552
1738
  kind: "hasMany",
1553
- relationName: ${quote(sourceTableName)},
1739
+ relationName: ${quote(relationNameFor(sourceTableName, `${sourceTableName}_by_${fk.column_name}`))},
1554
1740
  target: ${relationTarget(targetCollectionCamel)},
1555
1741
  // the ${commentText(sourceTableName)}.${commentText(fk.column_name)} FK points back here
1556
1742
  foreignKeyOnTarget: ${quote(fk.column_name)}
@@ -1564,7 +1750,11 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1564
1750
  const jtMeta = tablesMap.get(jt);
1565
1751
  if (!jtMeta) continue;
1566
1752
  const joinFks = jtMeta.fks;
1567
- const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName);
1753
+ const keyPosition = (fk) => {
1754
+ const position = jtMeta.pks.indexOf(fk.column_name);
1755
+ return position === -1 ? Number.MAX_SAFE_INTEGER : position;
1756
+ };
1757
+ const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName).sort((a, b) => keyPosition(a) - keyPosition(b));
1568
1758
  if (selfRefFks.length === 2) {
1569
1759
  const thisFk = selfRefFks[0];
1570
1760
  const otherFk = selfRefFks[1];
@@ -1572,7 +1762,7 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1572
1762
  relationsOutput += `
1573
1763
  {
1574
1764
  kind: "manyToMany",
1575
- relationName: ${quote(relPropName)},
1765
+ relationName: ${quote(relationNameFor(relPropName, `${relPropName}_${jt}`))},
1576
1766
  target: ${relationTarget(toCollectionVarName(tableName))},
1577
1767
  through: {
1578
1768
  table: ${quote(jt)},
@@ -1583,7 +1773,14 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1583
1773
  continue;
1584
1774
  }
1585
1775
  const otherFk = joinFks.find((fk) => fk.foreign_table_name !== tableName);
1586
- if (otherFk) {
1776
+ const leadingTable = joinFks.find((fk) => fk.column_name === jtMeta.pks[0])?.foreign_table_name;
1777
+ const sortsFirst = otherFk ? [tableName, otherFk.foreign_table_name].sort()[0] : tableName;
1778
+ const declareHere = !leadingTable || leadingTable === sortsFirst || leadingTable === tableName;
1779
+ if (otherFk && !declareHere) relationsOutput += `
1780
+ // The many-to-many with ${commentText(otherFk.foreign_table_name)} through ${commentText(jt)} is declared on
1781
+ // ${commentText(otherFk.foreign_table_name)} only: declaring it here too would re-key ${commentText(jt)}
1782
+ // from (${commentText(jtMeta.pks.join(", "))}) on the next push.`;
1783
+ if (otherFk && declareHere && !skippedTables.has(otherFk.foreign_table_name)) {
1587
1784
  const targetTableName = otherFk.foreign_table_name;
1588
1785
  const targetCollectionCamel = importCollection(targetTableName);
1589
1786
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
@@ -1591,11 +1788,20 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1591
1788
  relationsOutput += `
1592
1789
  {
1593
1790
  kind: "manyToMany",
1594
- relationName: ${quote(targetTableName)},
1791
+ relationName: ${quote(relationNameFor(targetTableName, `${targetTableName}_via_${jt}`))},
1595
1792
  target: ${relationTarget(targetCollectionCamel)},${throughCode}
1596
1793
  },`;
1597
1794
  }
1598
1795
  }
1796
+ const search = reconstructSearchBlock({
1797
+ schema: context.metadata?.schema ?? "public",
1798
+ table: tableName,
1799
+ columns: meta.columns,
1800
+ comments: columnComments,
1801
+ keyByColumn
1802
+ });
1803
+ if (search.note) fileNotes.push(search.note);
1804
+ const searchBlock = search.block ?? "";
1599
1805
  const relationsBlock = relationsOutput ? `\n relations: [${relationsOutput}\n ],` : "";
1600
1806
  const sortedPropertiesOrder = sortPropertiesOrder(orderEntries);
1601
1807
  for (const key of sortedPropertiesOrder) propsOutput += propertyBlocks.get(key) || "";
@@ -1609,7 +1815,11 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1609
1815
  const titleProperty = deriveTitleProperty(derivedFacts);
1610
1816
  if (titleProperty) adminEntries.push(`display: { title: ${quote(asProperty(titleProperty))} }`);
1611
1817
  const kanbanProperty = deriveKanbanProperty(derivedFacts);
1612
- if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(asProperty(kanbanProperty))}\n }`);
1818
+ const orderProperty = kanbanProperty ? deriveKanbanOrderProperty(derivedFacts) : void 0;
1819
+ if (kanbanProperty && orderProperty) {
1820
+ adminEntries.push(`kanban: {\n columnProperty: ${quote(asProperty(kanbanProperty))}\n }`);
1821
+ adminEntries.push(`orderProperty: ${quote(asProperty(orderProperty))}`);
1822
+ } else if (kanbanProperty) adminEntries.push(`// A board over ${quote(asProperty(kanbanProperty))} fits this table, but a board needs a string column to keep each card's order in. Add one, then declare kanban: { columnProperty: ${quote(asProperty(kanbanProperty))} } and orderProperty: "<that property>".`);
1613
1823
  const sort = deriveSort(derivedFacts);
1614
1824
  if (sort) adminEntries.push(`sort: [${quote(asProperty(sort[0]))}, "desc"]`);
1615
1825
  }
@@ -1622,16 +1832,18 @@ function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap,
1622
1832
  const pgSchema = context.metadata?.schema;
1623
1833
  const schemaBlock = pgSchema && pgSchema !== "public" ? `\n schema: ${quote(pgSchema)},` : "";
1624
1834
  const classificationNote = classification && classification.role !== "entity" ? `\n// Introspected as a ${commentText(classification.role)}: ${commentText(classification.reason)}.\n` : "";
1835
+ for (const note of fileNotes) context.notes?.push(`${tableName}: ${note}`);
1836
+ const storageNote = fileNotes.length > 0 ? `\n// ⚠ Not reproduced exactly by \`rebase db push\` — read before pushing:\n${fileNotes.map((n) => `// - ${commentText(n)}`).join("\n")}\n` : "";
1625
1837
  const collectionVarName = toCollectionVarName(tableName);
1626
1838
  const [open, close] = builder === "annotation" ? [`const ${collectionVarName}: PostgresCollectionConfig = {`, "};"] : [`const ${collectionVarName} = defineCollection({`, "});"];
1627
1839
  const importLines = Array.from(imports);
1628
1840
  return `${[...importLines.filter((line) => !line.includes("from \"./")), ...importLines.filter((line) => line.includes("from \"./"))].join("\n")}
1629
- ${classificationNote}
1841
+ ${classificationNote}${storageNote}
1630
1842
  ${open}
1631
1843
  name: ${quote(collectionName)},
1632
1844
  singularName: ${quote(singular)},
1633
1845
  slug: ${quote(tableName)},
1634
- table: ${quote(tableName)},${schemaBlock}${descriptionBlock}
1846
+ table: ${quote(tableName)},${schemaBlock}${descriptionBlock}${searchBlock}
1635
1847
  properties: {${propsOutput}
1636
1848
  },${relationsBlock}${adminBlock}
1637
1849
  ${close}
@@ -1692,6 +1904,6 @@ function safeHostFromUrl(url) {
1692
1904
  return url.includes("@") ? url.split("@")[1] : "(local connection)";
1693
1905
  }
1694
1906
  //#endregion
1695
- export { isUsableFileName as _, generateCollectionFile as a, identifyJoinTables as c, singularize as d, classifyTables as f, countRowsUpTo as g, humanize as h, buildTablesMap as i, mergeIndexContent as l, mapPgType as m, COMMON_PACKAGE as n, generateIndexContent as o, lookupCandidates as p, buildEnumMap as r, getIconForTable as s, ADMIN_TYPES_PACKAGE as t, safeHostFromUrl as u, quoteRelation as v, readSchemaMetadata as y };
1907
+ export { compositeKeySkipReason as a, getIconForTable as c, safeHostFromUrl as d, singularize as f, humanize as g, mapPgType as h, buildTablesMap as i, identifyJoinTables as l, lookupCandidates as m, COMMON_PACKAGE as n, generateCollectionFile as o, classifyTables as p, buildEnumMap as r, generateIndexContent as s, ADMIN_TYPES_PACKAGE as t, mergeIndexContent as u };
1696
1908
 
1697
- //# sourceMappingURL=introspect-db-logic-WMuAfxvw.js.map
1909
+ //# sourceMappingURL=introspect-db-logic-C-Di8dA9.js.map