@lenne.tech/nest-server 12.1.0 → 12.2.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 (255) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +18 -6
  3. package/.claude/rules/configurable-features.md +5 -4
  4. package/.claude/rules/package-management.md +16 -1
  5. package/.claude/rules/persistence.md +26 -0
  6. package/.claude/rules/testing.md +102 -28
  7. package/CLAUDE.md +3 -2
  8. package/FRAMEWORK-API.md +17 -2
  9. package/README.md +24 -3
  10. package/dist/core/common/helpers/file.helper.js +3 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  14. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  15. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  16. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  17. package/dist/core/common/persistence/data-source.runtime.js +14 -19
  18. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  19. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  20. package/dist/core/common/persistence/id-format.d.ts +2 -0
  21. package/dist/core/common/persistence/id-format.js +14 -0
  22. package/dist/core/common/persistence/id-format.js.map +1 -1
  23. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  24. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  25. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  26. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  27. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  28. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  29. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  30. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  31. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  32. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  33. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  34. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  35. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  36. package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
  37. package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
  38. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  39. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  41. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  44. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
  49. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  50. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  51. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  52. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  53. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  54. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  55. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  56. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  57. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  58. package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
  59. package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
  60. package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
  61. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  62. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  63. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  64. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  65. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  66. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  67. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  68. package/dist/core/common/persistence/sync-model.js +138 -0
  69. package/dist/core/common/persistence/sync-model.js.map +1 -0
  70. package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
  71. package/dist/core/common/services/core-cron-jobs.service.js +269 -13
  72. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  73. package/dist/core/common/services/core-redis-pubsub.js +5 -5
  74. package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
  75. package/dist/core/common/services/core-redis.service.d.ts +6 -0
  76. package/dist/core/common/services/core-redis.service.js +57 -0
  77. package/dist/core/common/services/core-redis.service.js.map +1 -1
  78. package/dist/core/common/services/crud.service.d.ts +13 -1
  79. package/dist/core/common/services/crud.service.js +174 -16
  80. package/dist/core/common/services/crud.service.js.map +1 -1
  81. package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
  82. package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
  83. package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
  84. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
  86. package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
  87. package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
  88. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
  89. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
  90. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  91. package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
  92. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  93. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  94. package/dist/core/modules/error-code/error-codes.js +48 -0
  95. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  96. package/dist/core/modules/hub/hub-buffer.js +3 -4
  97. package/dist/core/modules/hub/hub-buffer.js.map +1 -1
  98. package/dist/core/modules/migrate/index.js.map +1 -1
  99. package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
  100. package/dist/core/modules/migrate/migration-runner.js +28 -2
  101. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  102. package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
  103. package/dist/core/modules/migrate/mongo-state-store.js +0 -5
  104. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  105. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  106. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  107. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  108. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  109. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  110. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  111. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  112. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  113. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  114. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  115. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  116. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  117. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  118. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  119. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  120. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  121. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  122. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  123. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  124. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  125. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  126. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  127. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  128. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  129. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  130. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  131. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  132. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  133. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  134. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  135. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  136. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  137. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  138. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  139. package/dist/core/modules/sync/core-sync.module.js +50 -0
  140. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  141. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  142. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  143. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  144. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  145. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  146. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  147. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  148. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  149. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  150. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  151. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  152. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  153. package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
  154. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  155. package/dist/core.module.js +14 -0
  156. package/dist/core.module.js.map +1 -1
  157. package/dist/index.d.ts +16 -0
  158. package/dist/index.js +16 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  161. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  162. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  163. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  164. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  165. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  166. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  167. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  168. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  169. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  170. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  171. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  172. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  173. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  174. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  175. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  176. package/docs/REQUEST-LIFECYCLE.md +3 -2
  177. package/docs/choosing-a-database.md +7 -4
  178. package/docs/offline-sync-protocol.md +410 -0
  179. package/docs/security-overrides.md +14 -0
  180. package/migration-guides/12.0.x-to-12.1.x.md +9 -4
  181. package/migration-guides/12.1.0-to-12.1.1.md +125 -0
  182. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  183. package/package.json +10 -8
  184. package/src/core/common/helpers/file.helper.ts +7 -5
  185. package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
  186. package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
  187. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  188. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  189. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  190. package/src/core/common/persistence/data-source.runtime.ts +22 -23
  191. package/src/core/common/persistence/data-store.interface.ts +65 -12
  192. package/src/core/common/persistence/id-format.ts +26 -0
  193. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  194. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  195. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  196. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  197. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  198. package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
  199. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  200. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  201. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  202. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
  203. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  204. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  205. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  206. package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
  207. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  208. package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
  209. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  210. package/src/core/common/persistence/sync-model.ts +270 -0
  211. package/src/core/common/services/core-cron-jobs.service.ts +441 -27
  212. package/src/core/common/services/core-redis-pubsub.ts +8 -6
  213. package/src/core/common/services/core-redis.service.ts +99 -0
  214. package/src/core/common/services/crud.service.ts +257 -19
  215. package/src/core/modules/better-auth/README.md +25 -1
  216. package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
  217. package/src/core/modules/better-auth/better-auth.config.ts +4 -3
  218. package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
  219. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
  220. package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
  221. package/src/core/modules/error-code/error-codes.ts +59 -0
  222. package/src/core/modules/hub/README.md +5 -2
  223. package/src/core/modules/hub/hub-buffer.ts +13 -7
  224. package/src/core/modules/migrate/README.md +20 -11
  225. package/src/core/modules/migrate/index.ts +1 -0
  226. package/src/core/modules/migrate/migration-runner.ts +68 -3
  227. package/src/core/modules/migrate/mongo-state-store.ts +7 -30
  228. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  229. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  230. package/src/core/modules/sync/README.md +188 -0
  231. package/src/core/modules/sync/SECURITY.md +65 -0
  232. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  233. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  234. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  235. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  236. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  237. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  238. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  239. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  240. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  241. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  242. package/src/core/modules/sync/core-sync.module.ts +75 -0
  243. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  244. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  245. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  246. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  247. package/src/core/modules/tenant/README.md +1 -0
  248. package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
  249. package/src/core.module.ts +25 -0
  250. package/src/index.ts +21 -0
  251. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  252. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  253. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  254. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  255. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -16,7 +16,12 @@ import {
16
16
  desiredMysqlTable,
17
17
  type MysqlDesiredColumn,
18
18
  type MysqlDesiredTable,
19
+ LIVE_KEY_PREFIX,
20
+ MYSQL_SYNC_COUNTERS,
21
+ MYSQL_SYNC_TRIGGER_VERSION,
19
22
  mysqlColumnDefinition,
23
+ mysqlSyncCountersStatement,
24
+ mysqlSyncTableStatements,
20
25
  NULL_SENTINEL_PREFIX,
21
26
  variantLabel,
22
27
  } from './mysql-schema.compiler.js';
@@ -72,12 +77,18 @@ interface CatalogTable {
72
77
  columns: Map<string, { collation: null | string; generated: boolean; type: string }>;
73
78
  /** framework-named index → unique */
74
79
  indexes: Map<string, boolean>;
80
+ /** Change-order triggers (`lt_sync_*`) on the table, as far as the connection may see them (the owner does) */
81
+ syncTriggers: Set<string>;
82
+ /** The trigger version the counter row records — readable by the application account too */
83
+ syncTriggerVersion: null | string;
75
84
  }
76
85
 
77
86
  interface Catalog {
78
87
  database: string;
79
88
  /** Whether the application account holds every DML right on the database */
80
89
  granted: boolean;
90
+ /** Whether the counter table of synchronized models exists */
91
+ syncCounters: boolean;
81
92
  tables: Map<string, CatalogTable>;
82
93
  }
83
94
 
@@ -109,6 +120,71 @@ export async function withMysqlSchemaLock<T>(owner: SqlExecutor, fn: (run: Run)
109
120
  });
110
121
  }
111
122
 
123
+ /** The collation of every table the framework creates — byte-wise, as identifiers and tokens compare on PostgreSQL */
124
+ const FRAMEWORK_COLLATION = 'utf8mb4_bin';
125
+ /**
126
+ * How long a collation switch waits for the metadata locks it needs, in seconds. `ALTER DATABASE` locks every table of
127
+ * the database exclusively, one after the other: behind an open transaction it waits, and every statement on the
128
+ * tables it already holds waits with it. The server's own default is a year
129
+ */
130
+ const COLLATION_SWITCH_LOCK_WAIT_SECONDS = 5;
131
+
132
+ /** Whether any of `tables` does not exist yet — only then does a library's migration create a table */
133
+ export async function anyTableMissing(run: Run, tables: string[]): Promise<boolean> {
134
+ if (!tables.length) {
135
+ return false;
136
+ }
137
+ const { rows } = await run({
138
+ params: tables,
139
+ text: `SELECT COUNT(*) AS n FROM information_schema.TABLES
140
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${tables.map((_, index) => `$${index + 1}`).join(', ')})`,
141
+ });
142
+ return Number(rows[0]?.n) < new Set(tables).size;
143
+ }
144
+
145
+ /**
146
+ * `fn` with the database's default collation switched to the framework's and back afterwards — for a library that
147
+ * creates its tables without naming a collation (Better-Auth). Its tables then compare byte-wise like the framework's,
148
+ * and its foreign keys to them can be created at all: MariaDB refuses a key between `utf8mb4_bin` (`users.id`) and the
149
+ * server's default (`Foreign key constraint is incorrectly formed`), and on MySQL tokens and identifiers would compare
150
+ * case-insensitively. Restored afterwards, so the project's own DDL keeps the default it was given. Runs as the owner
151
+ * under the schema lock (`run` is the lock's connection); a process that dies in between leaves `utf8mb4_bin` behind,
152
+ * which changes no existing table.
153
+ *
154
+ * Only for a run that creates a table (`anyTableMissing()`): a column added to an existing table takes that table's
155
+ * collation, and the switch locks every table of the database. Its wait is bounded, so a first boot behind a long
156
+ * transaction fails with an error instead of freezing the database.
157
+ */
158
+ export async function withFrameworkDefaultCollation<T>(run: Run, fn: () => Promise<T>): Promise<T> {
159
+ const { rows } = await run({
160
+ params: [],
161
+ text: `SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation
162
+ FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()`,
163
+ });
164
+ const { charset, collation } = (rows[0] ?? {}) as { charset?: string; collation?: string };
165
+ if (collation === FRAMEWORK_COLLATION) {
166
+ return fn();
167
+ }
168
+ if (!/^\w+$/.test(charset ?? '') || !/^\w+$/.test(collation ?? '')) {
169
+ throw new Error(`Cannot read the default collation of the database (found ${charset}/${collation})`);
170
+ }
171
+ const { rows: timeouts } = await run({ params: [], text: 'SELECT @@SESSION.lock_wait_timeout AS timeout' });
172
+ const previousTimeout = Number(timeouts[0]?.timeout);
173
+ await run({ params: [], text: `SET SESSION lock_wait_timeout = ${COLLATION_SWITCH_LOCK_WAIT_SECONDS}` });
174
+ try {
175
+ await run({ params: [], text: `ALTER DATABASE CHARACTER SET utf8mb4 COLLATE ${FRAMEWORK_COLLATION}` });
176
+ try {
177
+ return await fn();
178
+ } finally {
179
+ await run({ params: [], text: `ALTER DATABASE CHARACTER SET ${charset} COLLATE ${collation}` });
180
+ }
181
+ } finally {
182
+ if (Number.isInteger(previousTimeout) && previousTimeout > 0) {
183
+ await run({ params: [], text: `SET SESSION lock_wait_timeout = ${previousTimeout}` });
184
+ }
185
+ }
186
+ }
187
+
112
188
  /** Apply every automatic change as the OWNER, under the schema lock; refuse when a migration is needed. */
113
189
  export async function applyMysqlSchema(
114
190
  owner: SqlExecutor,
@@ -166,6 +242,13 @@ export async function planMysqlSchema(
166
242
  options.appAccount,
167
243
  );
168
244
  const changes: SchemaChange[] = [];
245
+ if (tables.some((table) => table.sync) && !catalog.syncCounters) {
246
+ changes.push({
247
+ description: `create the change-order counters ${MYSQL_SYNC_COUNTERS}`,
248
+ kind: 'automatic',
249
+ statements: [mysqlSyncCountersStatement()],
250
+ });
251
+ }
169
252
  if (!catalog.granted) {
170
253
  changes.push({
171
254
  description: `grant ${options.appAccount} SELECT, INSERT, UPDATE, DELETE on database ${catalog.database}`,
@@ -190,12 +273,53 @@ function planNewTable(table: MysqlDesiredTable): SchemaChange[] {
190
273
  {
191
274
  description: `create table ${table.name} (${table.model.name})`,
192
275
  kind: 'automatic',
193
- statements: [createMysqlTableStatement(table), ...table.indexes.map((index) => index.create)],
276
+ statements: [
277
+ createMysqlTableStatement(table),
278
+ ...table.indexes.map((index) => index.create),
279
+ ...(table.sync ? mysqlSyncTableStatements(table.name, table.sync) : []),
280
+ ],
194
281
  table: table.name,
195
282
  },
196
283
  ];
197
284
  }
198
285
 
286
+ /**
287
+ * A synchronized table's change order: both triggers of the current version and its counter row. Triggers of an earlier
288
+ * version are replaced; a table that is no longer synchronized keeps its triggers until a migration drops them
289
+ */
290
+ function planSync(table: MysqlDesiredTable, existing: CatalogTable): SchemaChange[] {
291
+ const name = table.name;
292
+ if (!table.sync) {
293
+ return existing.syncTriggers.size
294
+ ? [
295
+ {
296
+ description: `${name} is no longer synchronized; its change-order triggers are kept until a migration drops them`,
297
+ kind: 'notice',
298
+ statements: [],
299
+ table: name,
300
+ },
301
+ ]
302
+ : [];
303
+ }
304
+ if (existing.syncTriggerVersion === MYSQL_SYNC_TRIGGER_VERSION) {
305
+ return [];
306
+ }
307
+ const current = new Set([table.sync.insertTrigger, table.sync.updateTrigger]);
308
+ return [
309
+ {
310
+ description: `${existing.syncTriggerVersion ? 'replace' : 'add'} the change-order triggers of ${name}`,
311
+ kind: 'automatic',
312
+ statements: [
313
+ ...[...existing.syncTriggers]
314
+ .filter((trigger) => !current.has(trigger))
315
+ .map((trigger) => `DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(trigger)}`),
316
+ ...mysqlSyncTableStatements(name, table.sync),
317
+ ],
318
+ table: name,
319
+ },
320
+ ];
321
+ }
322
+
199
323
  function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): SchemaChange[] {
200
324
  const changes: SchemaChange[] = [];
201
325
  const name = table.name;
@@ -255,7 +379,12 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
255
379
  }
256
380
  }
257
381
  for (const column of existing.columns.keys()) {
258
- if (!declared.has(column) && !column.startsWith(NULL_SENTINEL_PREFIX) && !isForeignColumn(name, column)) {
382
+ if (
383
+ !declared.has(column) &&
384
+ !column.startsWith(NULL_SENTINEL_PREFIX) &&
385
+ !column.startsWith(LIVE_KEY_PREFIX) &&
386
+ !isForeignColumn(name, column)
387
+ ) {
259
388
  changes.push({
260
389
  description: `${name}.${column} is no longer declared; the column is kept until a migration drops it`,
261
390
  kind: 'notice',
@@ -284,16 +413,38 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
284
413
  }
285
414
  const sentinels = (index.sentinels ?? []).filter((sentinel) => deferred.has(sentinel) && !added.has(sentinel));
286
415
  sentinels.forEach((sentinel) => added.add(sentinel));
416
+ // A synchronized model's live-key columns read `deleted_at`: an existing model that became synchronized has none
417
+ // yet, and the boot that refuses this index never adds it
418
+ const deletedAt = table.columns.find((column) => column.name === 'deleted_at');
419
+ const needsDeletedAt =
420
+ !!table.sync &&
421
+ !!deletedAt &&
422
+ !existing.columns.has('deleted_at') &&
423
+ !added.has('deleted_at') &&
424
+ sentinels.some((sentinel) => sentinel.startsWith(LIVE_KEY_PREFIX));
425
+ if (needsDeletedAt) {
426
+ added.add('deleted_at');
427
+ }
428
+ // The framework unique index over the same fields that the model no longer declares is the one this live-only index
429
+ // replaces — a tombstone, which keeps no values, would collide on it
430
+ const stem = indexStem(index.name);
431
+ const superseded = table.sync
432
+ ? [...existing.indexes].filter(([old, unique]) => unique && !wanted.has(old) && indexStem(old) === stem)
433
+ : [];
287
434
  changes.push({
288
435
  description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
289
436
  // A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
290
437
  kind: index.unique ? 'migration' : 'automatic',
291
438
  statements: [
439
+ ...(needsDeletedAt
440
+ ? [`ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition({ ...deletedAt!, primaryKey: false })}`]
441
+ : []),
292
442
  ...sentinels.map(
293
443
  (sentinel) =>
294
444
  `ALTER TABLE ${quoted} ADD COLUMN ${mysqlColumnDefinition(table.columns.find((column) => column.name === sentinel)!)}`,
295
445
  ),
296
446
  index.create,
447
+ ...superseded.map(([old]) => `DROP INDEX ${quoteMysqlIdentifier(old)} ON ${quoted}`),
297
448
  ],
298
449
  table: name,
299
450
  });
@@ -308,9 +459,15 @@ function planExistingTable(table: MysqlDesiredTable, existing: CatalogTable): Sc
308
459
  });
309
460
  }
310
461
  }
462
+ changes.push(...planSync(table, existing));
311
463
  return changes;
312
464
  }
313
465
 
466
+ /** A framework index name without its definition hash (`ux_<table>__<fields>_<hash>`) */
467
+ function indexStem(name: string): string {
468
+ return name.replace(/_[0-9a-f]{8}$/, '');
469
+ }
470
+
314
471
  function sameType(column: MysqlDesiredColumn, current: { collation: null | string; type: string }): boolean {
315
472
  if (column.type !== current.type) {
316
473
  return false;
@@ -368,20 +525,48 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
368
525
  text: `SELECT PRIVILEGE_TYPE AS privilege FROM information_schema.SCHEMA_PRIVILEGES
369
526
  WHERE GRANTEE = $1 AND TABLE_SCHEMA = DATABASE()`,
370
527
  });
528
+ const triggerResult = await run({
529
+ params: tables,
530
+ text: `SELECT EVENT_OBJECT_TABLE AS t, TRIGGER_NAME AS name FROM information_schema.TRIGGERS
531
+ WHERE TRIGGER_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE IN (${list}) AND TRIGGER_NAME LIKE 'lt\\_sync\\_%'`,
532
+ });
533
+ const countersResult = await run({
534
+ params: [MYSQL_SYNC_COUNTERS],
535
+ text: `SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = $1`,
536
+ });
537
+ const counterRows = Number(countersResult.rows[0]?.n)
538
+ ? (
539
+ await run({
540
+ params: tables,
541
+ text: `SELECT \`model\` AS t, \`triggers\` AS version FROM ${quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS)} WHERE \`model\` IN (${list})`,
542
+ })
543
+ ).rows
544
+ : [];
371
545
 
372
546
  const catalog: Catalog = {
373
547
  database,
374
548
  granted: DML.every((privilege) => grantResult.rows.some((row) => row.privilege === privilege)),
549
+ syncCounters: Number(countersResult.rows[0]?.n) > 0,
375
550
  tables: new Map(),
376
551
  };
377
552
  const table = (name: string) => {
378
553
  let entry = catalog.tables.get(name);
379
554
  if (!entry) {
380
- entry = { checks: new Set(), columns: new Map(), indexes: new Map() };
555
+ entry = {
556
+ checks: new Set(),
557
+ columns: new Map(),
558
+ indexes: new Map(),
559
+ syncTriggers: new Set(),
560
+ syncTriggerVersion: null,
561
+ };
381
562
  catalog.tables.set(name, entry);
382
563
  }
383
564
  return entry;
384
565
  };
566
+ for (const row of triggerResult.rows) {
567
+ table(row.t).syncTriggers.add(row.name);
568
+ }
569
+
385
570
  for (const row of columnResult.rows) {
386
571
  table(row.t).columns.set(row.c, {
387
572
  collation: row.collation ?? null,
@@ -395,6 +580,12 @@ async function readCatalog(run: Run, tables: string[], appAccount: string): Prom
395
580
  for (const row of checkResult.rows) {
396
581
  table(row.t).checks.add(row.name);
397
582
  }
583
+ // After the columns: only a table that exists has a counter row worth reading
584
+ for (const row of counterRows) {
585
+ if (catalog.tables.has(row.t)) {
586
+ table(row.t).syncTriggerVersion = row.version ?? null;
587
+ }
588
+ }
398
589
  return catalog;
399
590
  }
400
591
 
@@ -2,6 +2,7 @@ import type { FieldDescriptor, IndexDescriptor, ModelDescriptor } from '../../mo
2
2
  import { UnsupportedQueryError } from '../sql-errors.js';
3
3
  import { columnName, quoteIdentifier, SqlLiterals, sqlLiteral } from '../sql-naming.js';
4
4
  import { schemaHash, shortName, tableName } from '../sql-schema.shared.js';
5
+ import { isLiveFilter, SYNC_SEQUENCE_FIELD } from '../../sync-model.js';
5
6
  import { compilePostgresWhere } from './postgres-where.compiler.js';
6
7
 
7
8
  export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
@@ -23,6 +24,15 @@ export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
23
24
  /** Any string item that starts with `s_` after leading whitespace, case-insensitive (`looksLikeSystemRole`) */
24
25
  const SYSTEM_ROLE_PATH = '$[*] ? (@.type() == "string" && @ like_regex "^\\\\s*s_" flag "i")';
25
26
 
27
+ /**
28
+ * Bumped whenever the change-order trigger (`lt_sync_stamp`) changes, so the migrator replaces the function and
29
+ * every table's trigger — the trigger's name carries the version, because a trigger has no comment the catalog query
30
+ * could read alongside the table
31
+ */
32
+ export const SYNC_TRIGGER_VERSION = 'lt:sync:v1';
33
+ export const SYNC_STAMP_FUNCTION = 'lt_sync_stamp';
34
+ export const SYNC_TRIGGER = 'lt_sync_stamp_v1';
35
+
26
36
  /** Bumped whenever the generated guard function or policies change, so the migrator replaces them */
27
37
  export const RLS_VERSION = 'lt:tenant:v1';
28
38
  export const TENANT_FILTER_FUNCTION = 'lt_tenant_filter';
@@ -41,6 +51,12 @@ export interface DesiredColumn {
41
51
  renamedFrom?: string;
42
52
  }
43
53
 
54
+ /** The objects that number a synchronized model's changes (offline-sync plan §3.4) */
55
+ export interface DesiredSync {
56
+ /** The table's sequence: `nextval()` under the model's advisory lock gives commit order */
57
+ sequence: string;
58
+ }
59
+
44
60
  export interface DesiredIndex {
45
61
  /** The CREATE statement */
46
62
  create: string;
@@ -61,6 +77,8 @@ export interface DesiredTable {
61
77
  name: string;
62
78
  /** What the model declares but the table cannot carry (text indexes, …), reported once */
63
79
  notices: string[];
80
+ /** Present for a synchronized model: its change-order sequence and trigger */
81
+ sync?: DesiredSync;
64
82
  /** Row-level security on `tenant_id` */
65
83
  tenantIsolated: boolean;
66
84
  }
@@ -119,7 +137,13 @@ export function desiredTable(model: ModelDescriptor, options: PostgresSchemaOpti
119
137
  indexes.push(desiredIndex(model, name, { fields: { tenantId: 1 }, options: {} }) as DesiredIndex);
120
138
  }
121
139
 
122
- return { checks, columns, indexes, model, name, notices, tenantIsolated };
140
+ const sync = model.sync && model.fields[SYNC_SEQUENCE_FIELD] ? { sequence: syncSequenceName(name) } : undefined;
141
+ return { checks, columns, indexes, model, name, notices, ...(sync ? { sync } : {}), tenantIsolated };
142
+ }
143
+
144
+ /** The change-order sequence of a synchronized model's table */
145
+ export function syncSequenceName(table: string): string {
146
+ return shortName('lt_sync', table, ['seq']);
123
147
  }
124
148
 
125
149
  function desiredColumn(field: FieldDescriptor): DesiredColumn {
@@ -223,7 +247,9 @@ function desiredIndex(model: ModelDescriptor, table: string, index: IndexDescrip
223
247
  const using = gin.length
224
248
  ? ` USING gin (${gin[0].expression} jsonb_path_ops)`
225
249
  : ` (${parts.map((part) => part.sql).join(', ')})`;
226
- const nulls = unique && !conditions.length ? ' NULLS NOT DISTINCT' : '';
250
+ // A partial index admits NULL repeatedly unless told otherwise. That is harmless where the condition excludes NULL
251
+ // (sparse), but a synchronized model's live-record condition keeps MongoDB's rule: a second live NULL collides
252
+ const nulls = unique && (!conditions.length || (!sparse && isLiveFilter(partial))) ? ' NULLS NOT DISTINCT' : '';
227
253
  const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
228
254
  return {
229
255
  create: `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(table)}${using}${nulls}${where}`,
@@ -280,6 +306,52 @@ export function grantStatement(table: string, appRole: string): string {
280
306
  return `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoteIdentifier(table)} TO ${quoteIdentifier(appRole)}`;
281
307
  }
282
308
 
309
+ /**
310
+ * The trigger function that numbers a synchronized model's writes (offline-sync plan §3.4, C1d; DEV-3900 measured
311
+ * the advisory lock against a counter row). A BEFORE ROW trigger runs after the row is locked, so a write takes its
312
+ * row first and the order last; the transaction-scoped advisory lock is released only after the commit is visible,
313
+ * so sequence order is commit order, and `nextval()` leaves no row version a long snapshot could pile up.
314
+ *
315
+ * A value the statement sets itself is kept: the store's multi-row writes lock their rows, take their numbers and set
316
+ * them explicitly in their final statement.
317
+ */
318
+ export function syncFunctionStatements(): string[] {
319
+ const fn = quoteIdentifier(SYNC_STAMP_FUNCTION);
320
+ return [
321
+ `CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger
322
+ LANGUAGE plpgsql SET search_path = pg_catalog AS $lt$
323
+ BEGIN
324
+ IF TG_OP = 'INSERT' THEN
325
+ IF NEW.sync_seq IS NOT NULL THEN
326
+ RETURN NEW;
327
+ END IF;
328
+ ELSIF NEW.sync_seq IS DISTINCT FROM OLD.sync_seq THEN
329
+ RETURN NEW;
330
+ END IF;
331
+ PERFORM pg_advisory_xact_lock(hashtext('lenne.tech:sync:' || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME));
332
+ NEW.sync_seq := nextval(format('%I.%I', TG_TABLE_SCHEMA, TG_ARGV[0])::regclass);
333
+ RETURN NEW;
334
+ END
335
+ $lt$`,
336
+ `COMMENT ON FUNCTION ${fn}() IS ${sqlLiteral(SYNC_TRIGGER_VERSION)}`,
337
+ ];
338
+ }
339
+
340
+ /**
341
+ * A synchronized table's sequence, the application role's use of it, the trigger, and the numbering of rows written
342
+ * before the table was synchronized (in the order of their ids, i.e. of their creation)
343
+ */
344
+ export function syncTableStatements(table: string, sync: DesiredSync, appRole: string): string[] {
345
+ const quoted = quoteIdentifier(table);
346
+ const sequence = quoteIdentifier(sync.sequence);
347
+ return [
348
+ `CREATE SEQUENCE IF NOT EXISTS ${sequence}`,
349
+ `GRANT USAGE ON SEQUENCE ${sequence} TO ${quoteIdentifier(appRole)}`,
350
+ `CREATE TRIGGER ${quoteIdentifier(SYNC_TRIGGER)} BEFORE INSERT OR UPDATE ON ${quoted} FOR EACH ROW EXECUTE FUNCTION ${quoteIdentifier(SYNC_STAMP_FUNCTION)}(${sqlLiteral(sync.sequence)})`,
351
+ `UPDATE ${quoted} AS t SET "sync_seq" = n.seq FROM (SELECT "id", nextval(${sqlLiteral(sync.sequence)}::regclass) AS seq FROM (SELECT "id" FROM ${quoted} WHERE "sync_seq" IS NULL ORDER BY "id") AS o) AS n WHERE t."id" = n."id"`,
352
+ ];
353
+ }
354
+
283
355
  /**
284
356
  * The guard every tenant policy calls. It reads the scope the store wrote into the transaction (`sql-session.ts`)
285
357
  * and RAISES `42501` without one, so a query on a tenant table outside the framework's context — a forgotten scope,
@@ -18,6 +18,11 @@ import {
18
18
  grantStatement,
19
19
  type PostgresSchemaOptions,
20
20
  RLS_VERSION,
21
+ SYNC_STAMP_FUNCTION,
22
+ SYNC_TRIGGER,
23
+ SYNC_TRIGGER_VERSION,
24
+ syncFunctionStatements,
25
+ syncTableStatements,
21
26
  TENANT_FILTER_FUNCTION,
22
27
  TENANT_POLICY,
23
28
  tenantFilterFunctionStatements,
@@ -63,10 +68,15 @@ interface CatalogTable {
63
68
  granted: boolean;
64
69
  policyVersion: null | string;
65
70
  rls: boolean;
71
+ /** Change-order triggers (`lt_sync_*`) on the table */
72
+ syncTriggers: Set<string>;
66
73
  }
67
74
 
68
75
  interface Catalog {
69
76
  functionVersion: null | string;
77
+ /** Sequences of synchronized tables → whether the application role may use them */
78
+ sequences: Map<string, boolean>;
79
+ syncFunctionVersion: null | string;
70
80
  schema: string;
71
81
  /** Whether the application role may use the schema */
72
82
  schemaUsage: boolean;
@@ -124,9 +134,18 @@ export async function planPostgresSchema(
124
134
  run,
125
135
  tables.map((table) => table.name),
126
136
  options.appRole,
137
+ tables.flatMap((table) => (table.sync ? [table.sync.sequence] : [])),
127
138
  );
128
139
  const changes: SchemaChange[] = [];
129
140
 
141
+ if (tables.some((table) => table.sync) && catalog.syncFunctionVersion !== SYNC_TRIGGER_VERSION) {
142
+ changes.push({
143
+ description: `${catalog.syncFunctionVersion ? 'update' : 'create'} the change-order function ${SYNC_STAMP_FUNCTION}()`,
144
+ kind: 'automatic',
145
+ statements: syncFunctionStatements(),
146
+ });
147
+ }
148
+
130
149
  if (tables.some((table) => table.tenantIsolated) && catalog.functionVersion !== RLS_VERSION) {
131
150
  changes.push({
132
151
  description: `${catalog.functionVersion ? 'update' : 'create'} the tenant guard function ${TENANT_FILTER_FUNCTION}()`,
@@ -146,7 +165,9 @@ export async function planPostgresSchema(
146
165
  changes.push({ description: notice, kind: 'notice', statements: [], table: table.name });
147
166
  }
148
167
  const existing = catalog.tables.get(table.name);
149
- changes.push(...(existing ? planExistingTable(table, existing, options) : planNewTable(table, options)));
168
+ changes.push(
169
+ ...(existing ? planExistingTable(table, existing, catalog.sequences, options) : planNewTable(table, options)),
170
+ );
150
171
  }
151
172
  return changes;
152
173
  }
@@ -161,6 +182,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
161
182
  ...table.indexes.map((index) => index.create),
162
183
  ...(table.tenantIsolated ? tenantPolicyStatements(table.name) : []),
163
184
  grantStatement(table.name, options.appRole),
185
+ ...(table.sync ? syncTableStatements(table.name, table.sync, options.appRole) : []),
164
186
  ],
165
187
  table: table.name,
166
188
  },
@@ -170,6 +192,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
170
192
  function planExistingTable(
171
193
  table: DesiredTable,
172
194
  existing: CatalogTable,
195
+ sequences: Map<string, boolean>,
173
196
  options: PostgresSchemaOptions,
174
197
  ): SchemaChange[] {
175
198
  const changes: SchemaChange[] = [];
@@ -249,7 +272,7 @@ function planExistingTable(
249
272
  description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
250
273
  // A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
251
274
  kind: index.unique ? 'migration' : 'automatic',
252
- statements: [index.create],
275
+ statements: index.unique ? liveUniqueMigration(table, existing, index, wanted) : [index.create],
253
276
  table: name,
254
277
  });
255
278
  }
@@ -294,9 +317,97 @@ function planExistingTable(
294
317
  table: name,
295
318
  });
296
319
  }
320
+ changes.push(...planSync(table, existing, sequences, options));
297
321
  return changes;
298
322
  }
299
323
 
324
+ /**
325
+ * A synchronized table's change order: the trigger of the current version, its sequence and the application role's
326
+ * use of it. A trigger of an earlier version is replaced; a table that is no longer synchronized keeps its trigger
327
+ * (and its numbering) until a migration removes it
328
+ */
329
+ function planSync(
330
+ table: DesiredTable,
331
+ existing: CatalogTable,
332
+ sequences: Map<string, boolean>,
333
+ options: PostgresSchemaOptions,
334
+ ): SchemaChange[] {
335
+ const name = table.name;
336
+ const quoted = quoteIdentifier(name);
337
+ if (!table.sync) {
338
+ return existing.syncTriggers.size
339
+ ? [
340
+ {
341
+ description: `${name} is no longer synchronized; its change-order trigger is kept until a migration drops it`,
342
+ kind: 'notice',
343
+ statements: [],
344
+ table: name,
345
+ },
346
+ ]
347
+ : [];
348
+ }
349
+ if (!existing.syncTriggers.has(SYNC_TRIGGER)) {
350
+ const outdated = [...existing.syncTriggers].map(
351
+ (trigger) => `DROP TRIGGER IF EXISTS ${quoteIdentifier(trigger)} ON ${quoted}`,
352
+ );
353
+ return [
354
+ {
355
+ description: `${outdated.length ? 'replace' : 'add'} the change-order trigger of ${name}`,
356
+ kind: 'automatic',
357
+ statements: [...outdated, ...syncTableStatements(name, table.sync, options.appRole)],
358
+ table: name,
359
+ },
360
+ ];
361
+ }
362
+ if (!sequences.get(table.sync.sequence)) {
363
+ return [
364
+ {
365
+ description: `let ${options.appRole} use the change-order sequence of ${name}`,
366
+ kind: 'automatic',
367
+ statements: syncTableStatements(name, table.sync, options.appRole).slice(0, 2),
368
+ table: name,
369
+ },
370
+ ];
371
+ }
372
+ return [];
373
+ }
374
+
375
+ /**
376
+ * The statements of a new unique index's migration. A synchronized model's index reads `deleted_at`: when the table
377
+ * does not have it yet (an existing model that became synchronized), the column comes first — the boot that refuses the
378
+ * index never adds it. A framework unique index over the same fields that the model no longer declares is the one the
379
+ * live-only index replaces: it goes last, because a tombstone (which keeps no values) would collide on it
380
+ */
381
+ function liveUniqueMigration(
382
+ table: DesiredTable,
383
+ existing: CatalogTable,
384
+ index: DesiredTable['indexes'][number],
385
+ wanted: Set<string>,
386
+ ): string[] {
387
+ const statements: string[] = [];
388
+ const deletedAt = table.columns.find((column) => column.name === 'deleted_at');
389
+ if (table.sync && deletedAt && !existing.columns.has('deleted_at') && index.create.includes('"deleted_at"')) {
390
+ statements.push(
391
+ `ALTER TABLE ${quoteIdentifier(table.name)} ADD COLUMN ${columnDefinition({ ...deletedAt, primaryKey: false })}`,
392
+ );
393
+ }
394
+ statements.push(index.create);
395
+ if (table.sync) {
396
+ const stem = indexStem(index.name);
397
+ for (const [old, unique] of existing.indexes) {
398
+ if (unique && !wanted.has(old) && indexStem(old) === stem) {
399
+ statements.push(`DROP INDEX IF EXISTS ${quoteIdentifier(old)}`);
400
+ }
401
+ }
402
+ }
403
+ return statements;
404
+ }
405
+
406
+ /** A framework index name without its definition hash (`ux_<table>__<fields>_<hash>`) */
407
+ function indexStem(name: string): string {
408
+ return name.replace(/_[0-9a-f]{8}$/, '');
409
+ }
410
+
300
411
  function sameType(
301
412
  column: DesiredTable['columns'][number],
302
413
  current: { collation: null | string; type: string },
@@ -321,7 +432,7 @@ function typeChange(
321
432
  };
322
433
  }
323
434
 
324
- async function readCatalog(run: Run, tables: string[], appRole: string): Promise<Catalog> {
435
+ async function readCatalog(run: Run, tables: string[], appRole: string, sequences: string[]): Promise<Catalog> {
325
436
  // One query after the other: inside the migration transaction they share a single connection
326
437
  const schemaResult = await run({
327
438
  params: [appRole],
@@ -359,14 +470,31 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
359
470
  WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND k.contype = 'c'`,
360
471
  });
361
472
  const functionResult = await run({
362
- params: [TENANT_FILTER_FUNCTION],
363
- text: `SELECT obj_description(p.oid, 'pg_proc') AS version
473
+ params: [[TENANT_FILTER_FUNCTION, SYNC_STAMP_FUNCTION]],
474
+ text: `SELECT p.proname AS name, obj_description(p.oid, 'pg_proc') AS version
364
475
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
365
- WHERE n.nspname = current_schema() AND p.proname = $1 AND p.pronargs = 0`,
476
+ WHERE n.nspname = current_schema() AND p.proname = ANY($1) AND p.pronargs = 0`,
366
477
  });
478
+ const triggerResult = await run({
479
+ params: [tables],
480
+ text: `SELECT c.relname AS table, t.tgname AS name
481
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
482
+ WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND NOT t.tgisinternal AND t.tgname LIKE 'lt\\_sync\\_%'`,
483
+ });
484
+ const sequenceResult = sequences.length
485
+ ? await run({
486
+ params: [sequences, appRole],
487
+ text: `SELECT c.relname AS name, has_sequence_privilege($2, c.oid, 'USAGE') AS usage
488
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
489
+ WHERE n.nspname = current_schema() AND c.relkind = 'S' AND c.relname = ANY($1)`,
490
+ })
491
+ : { rowCount: 0, rows: [] };
492
+ const versionOf = (fn: string) => functionResult.rows.find((row) => row.name === fn)?.version ?? null;
367
493
 
368
494
  const catalog: Catalog = {
369
- functionVersion: functionResult.rows[0]?.version ?? null,
495
+ functionVersion: versionOf(TENANT_FILTER_FUNCTION),
496
+ sequences: new Map(sequenceResult.rows.map((row: { name: string; usage: boolean }) => [row.name, row.usage])),
497
+ syncFunctionVersion: versionOf(SYNC_STAMP_FUNCTION),
370
498
  schema: schemaResult.rows[0].schema,
371
499
  schemaUsage: schemaResult.rows[0].usage,
372
500
  tables: new Map(),
@@ -380,8 +508,12 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
380
508
  indexes: new Map(),
381
509
  policyVersion: row.policy_version ?? null,
382
510
  rls: row.rls,
511
+ syncTriggers: new Set(),
383
512
  });
384
513
  }
514
+ for (const row of triggerResult.rows) {
515
+ catalog.tables.get(row.table)?.syncTriggers.add(row.name);
516
+ }
385
517
  for (const row of columnResult.rows) {
386
518
  catalog.tables.get(row.table)?.columns.set(row.name, { collation: row.collation ?? null, type: row.type });
387
519
  }