@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
@@ -2,8 +2,10 @@ import { BadRequestException, Logger } from '@nestjs/common';
2
2
 
3
3
  import type {
4
4
  DataStoreUpdate,
5
+ DeleteOptions,
5
6
  FindOptions,
6
7
  PushOptions,
8
+ ReadOptions,
7
9
  StoreCapabilities,
8
10
  StoredRecord,
9
11
  UpdateOptions,
@@ -21,7 +23,9 @@ import {
21
23
  assertSelectFields,
22
24
  } from '../../store-guards.js';
23
25
  import { castFilterValue } from '../sql-cast.js';
24
- import { POSTGRES_STORE_CAPABILITIES, SqlDataStore, type SqlDataStoreOptions } from '../sql-data-store.js';
26
+ import { POSTGRES_STORE_CAPABILITIES, SqlDataStore, type SqlDataStoreOptions, type SqlRun } from '../sql-data-store.js';
27
+ import { tableName } from '../sql-schema.shared.js';
28
+ import { MYSQL_SYNC_COUNTERS } from './mysql-schema.compiler.js';
25
29
  import { DuplicateKeyError, UnsupportedQueryError } from '../sql-errors.js';
26
30
  import type { SqlQueryResult, SqlStatement } from '../sql-executor.interface.js';
27
31
  import { columnName, quoteMysqlIdentifier, SqlParams } from '../sql-naming.js';
@@ -71,7 +75,6 @@ type Run = <R = Record<string, any>>(statement: SqlStatement) => Promise<SqlQuer
71
75
  * gives MongoDB's findOneAndUpdate guarantee without a retry.
72
76
  */
73
77
  export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
74
- override readonly capabilities = MYSQL_STORE_CAPABILITIES;
75
78
  override readonly driver: string = 'mysql';
76
79
  protected override readonly logger = new Logger(MysqlDataStore.name);
77
80
  private readonly variant: MysqlVariant;
@@ -82,13 +85,44 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
82
85
  this.driver = executor.variant;
83
86
  }
84
87
 
88
+ protected override baseCapabilities(): Readonly<StoreCapabilities> {
89
+ return MYSQL_STORE_CAPABILITIES;
90
+ }
91
+
92
+ /**
93
+ * Every store write of a synchronized model takes its change number last: `FOR UPDATE` locks index gaps here, and an
94
+ * insert waits for them after its BEFORE INSERT trigger ran — a trigger holding the counter meanwhile would deadlock
95
+ * with a multi-row write that locked those gaps and now asks for the counter (measured, DEV-3900 review)
96
+ */
97
+ protected override numbersLast(): boolean {
98
+ return true;
99
+ }
100
+
101
+ /** A single-column write (pullFromArray, a one-stage push) — in a transaction, so a synchronized row is numbered */
102
+ protected override async updateColumn(id: string, column: string, value: string, params: SqlParams): Promise<void> {
103
+ if (!this.model.sync) {
104
+ return super.updateColumn(id, column, value, params);
105
+ }
106
+ const { $setOnInsert: _insertOnly, ...stamped } = await this.beforeUpdate({});
107
+ const assignments = [`${column} = ${value}`, ...this.compileUpdate(stamped, params)];
108
+ await this.transaction(async (run) => {
109
+ const { rowCount } = await run({
110
+ params: params.values,
111
+ text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params)}`,
112
+ });
113
+ if (rowCount) {
114
+ await this.numberPending(run, [id]);
115
+ }
116
+ });
117
+ }
118
+
85
119
  // -- reads ----------------------------------------------------------------------------------------------
86
120
 
87
- override async count(where?: Where<T>): Promise<number> {
121
+ override async count(where?: Where<T>, options: ReadOptions = {}): Promise<number> {
88
122
  const params = new SqlParams();
89
123
  const { rows } = await this.run<{ n: number | string }>({
90
124
  params: params.values,
91
- text: `SELECT COUNT(*) AS n FROM ${this.table} WHERE ${this.where(where, params)}`,
125
+ text: `SELECT COUNT(*) AS n FROM ${this.table} WHERE ${this.where(where, params, options)}`,
92
126
  });
93
127
  return Number(rows[0].n);
94
128
  }
@@ -109,10 +143,10 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
109
143
  return { count: Number(rows[0].n), sum: Number(rows[0].total) };
110
144
  }
111
145
 
112
- override async distinct(field: string, where?: Where<T>): Promise<unknown[]> {
146
+ override async distinct(field: string, where?: Where<T>, options: ReadOptions = {}): Promise<unknown[]> {
113
147
  const params = new SqlParams();
114
148
  const path = compileMysqlPath(this.variant, this.model, field, params);
115
- const condition = this.where(where, params);
149
+ const condition = this.where(where, params, options);
116
150
  if (path.kind === 'array') {
117
151
  const { rows } = await this.run<{ v: unknown }>({
118
152
  params: params.values,
@@ -133,7 +167,7 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
133
167
  override async find(where?: Where<T>, options: FindOptions = {}): Promise<StoredRecord<T>[]> {
134
168
  const sample = options.sample === undefined ? undefined : assertSampleSize(options.sample);
135
169
  const params = new SqlParams();
136
- let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params)}`;
170
+ let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params, options)}`;
137
171
  text += this.orderBy(options, params);
138
172
  if (options.limit !== undefined) {
139
173
  text += ` LIMIT ${params.add(assertNonNegativeInteger(options.limit, 'limit'))}`;
@@ -153,14 +187,17 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
153
187
 
154
188
  override async insert(record: Partial<T>): Promise<StoredRecord<T>> {
155
189
  const doc = await this.beforeInsert({ ...(record as Record<string, any>) });
156
- const params = new SqlParams();
157
- const { columns, id, values } = this.insertValues(doc, params);
158
- return this.transaction(async (run) => {
159
- await run({
160
- params: params.values,
161
- text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')})`,
190
+ return this.withServerId(doc, (prepared) => {
191
+ const params = new SqlParams();
192
+ const { columns, id, values } = this.insertValues(prepared, params);
193
+ return this.transaction(async (run) => {
194
+ await run({
195
+ params: params.values,
196
+ text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')})`,
197
+ });
198
+ await this.numberPending(run, [id]);
199
+ return (await this.readById(run, id))!;
162
200
  });
163
- return (await this.readById(run, id))!;
164
201
  });
165
202
  }
166
203
 
@@ -173,28 +210,36 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
173
210
  const params = new SqlParams();
174
211
  const assignments = this.compileUpdate(patch, params);
175
212
  if (!assignments.length) {
176
- return this.findById(id);
213
+ return this.findById(id, options);
177
214
  }
178
- const condition = this.where({ id } as Where<T>, params);
215
+ const condition = this.where({ id } as Where<T>, params, options);
179
216
  return this.transaction(async (run) => {
180
217
  const { rowCount } = await run({
181
218
  params: params.values,
182
219
  text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${condition}`,
183
220
  });
184
221
  // The driver counts MATCHED rows (CLIENT_FOUND_ROWS), so an update that changes nothing still finds its record
185
- return rowCount ? this.readById(run, id) : null;
222
+ if (!rowCount) {
223
+ return null;
224
+ }
225
+ await this.numberPending(run, [id]);
226
+ return this.readById(run, id);
186
227
  });
187
228
  }
188
229
 
189
- override async updateOne(where: Where<T>, update: DataStoreUpdate<T>): Promise<null | StoredRecord<T>> {
190
- const patch = await this.preparePatch(update);
230
+ override async updateOne(
231
+ where: Where<T>,
232
+ update: DataStoreUpdate<T>,
233
+ options: UpdateOptions = {},
234
+ ): Promise<null | StoredRecord<T>> {
235
+ const patch = await this.preparePatch(update, options);
191
236
  const params = new SqlParams();
192
237
  const assignments = this.compileUpdate(patch, params);
193
238
  if (!assignments.length) {
194
- return this.findOne(where);
239
+ return this.findOne(where, options);
195
240
  }
196
241
  return this.transaction(async (run) => {
197
- const id = await this.lockFirst(run, where);
242
+ const id = await this.lockFirst(run, where, options);
198
243
  if (id === undefined) {
199
244
  return null;
200
245
  }
@@ -202,16 +247,19 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
202
247
  params: params.values,
203
248
  text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE \`id\` = ${params.add(id)}`,
204
249
  });
250
+ await this.numberPending(run, [id]);
205
251
  return this.readById(run, id);
206
252
  });
207
253
  }
208
254
 
209
- override async deleteById(id: string): Promise<null | StoredRecord<T>> {
210
- return this.deleteFirst({ id } as Where<T>);
255
+ override async deleteById(id: string, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
256
+ return this.model.sync && !options.hardDelete
257
+ ? super.deleteById(id, options)
258
+ : this.deleteFirst({ id } as Where<T>, options);
211
259
  }
212
260
 
213
- override async deleteOne(where: Where<T>): Promise<null | StoredRecord<T>> {
214
- return this.deleteFirst(where);
261
+ override async deleteOne(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
262
+ return this.model.sync && !options.hardDelete ? super.deleteOne(where, options) : this.deleteFirst(where, options);
215
263
  }
216
264
 
217
265
  override async pushToArray(
@@ -307,6 +355,7 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
307
355
  return;
308
356
  }
309
357
  }
358
+ await this.numberPending(run, [id]);
310
359
  });
311
360
  }
312
361
 
@@ -418,6 +467,30 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
418
467
  return super.fromColumn(field, value);
419
468
  }
420
469
 
470
+ /**
471
+ * The counter row of this table (plan §3.4, C1d — MySQL and MariaDB update it in place, so a long snapshot costs no
472
+ * versions): raised by `count` at once, its row lock held until the transaction ends
473
+ */
474
+ protected override async takeChangeNumbers(run: SqlRun, count: number): Promise<string[]> {
475
+ const params = new SqlParams();
476
+ const key = tableName(this.model);
477
+ await run({
478
+ params: params.values,
479
+ text: `UPDATE ${this.quote(MYSQL_SYNC_COUNTERS)} SET \`seq\` = \`seq\` + ${params.add(count)} WHERE \`model\` = ${params.add(key)}`,
480
+ });
481
+ const read = new SqlParams();
482
+ const { rows } = await run<{ seq: number | string }>({
483
+ params: read.values,
484
+ text: `SELECT \`seq\` FROM ${this.quote(MYSQL_SYNC_COUNTERS)} WHERE \`model\` = ${read.add(key)}`,
485
+ });
486
+ const last = BigInt(rows[0].seq);
487
+ return Array.from({ length: count }, (_, index) => (last - BigInt(count - 1 - index)).toString());
488
+ }
489
+
490
+ protected override bigintParam(params: SqlParams, value: string): string {
491
+ return params.add(value);
492
+ }
493
+
421
494
  /**
422
495
  * MySQL / MariaDB errors as the answers the MongoDB path gives for the same situation. Matched by error NUMBER and
423
496
  * server: the driver names numbers from MySQL's list, and MariaDB's 4025 (a failed CHECK) is another error there
@@ -425,7 +498,8 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
425
498
  protected override mapError(error: unknown): unknown {
426
499
  const errno = Number((error as { errno?: unknown })?.errno);
427
500
  if (errno === MYSQL_ERRORS.duplicateEntry) {
428
- return new DuplicateKeyError(this.modelName);
501
+ const message = String((error as { message?: unknown })?.message ?? '');
502
+ return new DuplicateKeyError(this.modelName, /for key '(?:[^']*\.)?PRIMARY'/.test(message));
429
503
  }
430
504
  if (errno === MYSQL_ERRORS.dataTooLong) {
431
505
  return new BadRequestException(`${this.modelName}: a value is longer than its column allows`);
@@ -460,21 +534,25 @@ export class MysqlDataStore<T = Record<string, any>> extends SqlDataStore<T> {
460
534
  }
461
535
 
462
536
  /** The id of the first match, its row locked until the transaction ends */
463
- private async lockFirst(run: Run, where: Where<T>): Promise<string | undefined> {
537
+ private async lockFirst(
538
+ run: Run,
539
+ where: Where<T>,
540
+ options: { includeTombstones?: boolean } = {},
541
+ ): Promise<string | undefined> {
464
542
  const params = new SqlParams();
465
543
  const { rows } = await run<{ id: string }>({
466
544
  params: params.values,
467
- text: `SELECT \`id\` FROM ${this.table} WHERE ${this.where(where, params)} LIMIT 1 FOR UPDATE`,
545
+ text: `SELECT \`id\` FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
468
546
  });
469
547
  return rows[0]?.id;
470
548
  }
471
549
 
472
- private async deleteFirst(where: Where<T>): Promise<null | StoredRecord<T>> {
550
+ private async deleteFirst(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
473
551
  return this.transaction(async (run) => {
474
552
  const params = new SqlParams();
475
553
  const { rows } = await run({
476
554
  params: params.values,
477
- text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params)} LIMIT 1 FOR UPDATE`,
555
+ text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
478
556
  });
479
557
  if (!rows.length) {
480
558
  return null;
@@ -2,6 +2,7 @@ import type { FieldDescriptor, IndexDescriptor, ModelDescriptor } from '../../mo
2
2
  import { UnsupportedQueryError } from '../sql-errors.js';
3
3
  import { columnName, quoteMysqlIdentifier } from '../sql-naming.js';
4
4
  import { schemaHash, shortName, tableName } from '../sql-schema.shared.js';
5
+ import { SYNC_DELETED_FIELD, SYNC_SEQUENCE_FIELD } from '../../sync-model.js';
5
6
  import type { MysqlVariant } from './mysql-session.js';
6
7
 
7
8
  /**
@@ -48,6 +49,12 @@ export interface MysqlDesiredCheck {
48
49
  name: string;
49
50
  }
50
51
 
52
+ /** The triggers that number a synchronized model's changes (offline-sync plan §3.4) */
53
+ export interface MysqlDesiredSync {
54
+ insertTrigger: string;
55
+ updateTrigger: string;
56
+ }
57
+
51
58
  export interface MysqlDesiredTable {
52
59
  checks: MysqlDesiredCheck[];
53
60
  columns: MysqlDesiredColumn[];
@@ -56,14 +63,29 @@ export interface MysqlDesiredTable {
56
63
  name: string;
57
64
  /** What the model declares but the table cannot carry, reported once */
58
65
  notices: string[];
66
+ /** Present for a synchronized model: its change-order triggers */
67
+ sync?: MysqlDesiredSync;
59
68
  }
60
69
 
70
+ /** The counter rows of every synchronized table (offline-sync plan §3.4, C1d): one row per table, updated in place */
71
+ export const MYSQL_SYNC_COUNTERS = 'lt_sync_counters';
72
+ /**
73
+ * Bumped whenever the change-order triggers change: their names carry the version, so the migrator finds an outdated
74
+ * trigger by name and replaces it
75
+ */
76
+ export const MYSQL_SYNC_TRIGGER_VERSION = 'v1';
77
+
61
78
  /** Text types whose collation is `utf8mb4_bin` */
62
79
  const TEXT_TYPES = new Set(['char(36)', 'longtext', 'varchar(255)']);
63
80
  /** Kinds stored as strings — the null sentinel of a unique index is a string */
64
81
  const STRING_KINDS = new Set(['enum', 'id', 'ref', 'string', 'uuid']);
65
82
  /** Prefix of the virtual columns that carry a unique index's null sentinel */
66
83
  export const NULL_SENTINEL_PREFIX = 'lt_nn_';
84
+ /**
85
+ * Prefix of the virtual columns a synchronized model's unique index reads: the value while the record is live, NULL
86
+ * once it is a tombstone — MySQL and MariaDB have no partial index, and NULL may repeat in a unique one (plan §3.6)
87
+ */
88
+ export const LIVE_KEY_PREFIX = 'lt_lk_';
67
89
  /** InnoDB's limit for the columns of one index, in bytes (utf8mb4: four per character) */
68
90
  const MAX_INDEX_BYTES = 3072;
69
91
  const INDEX_BYTES: Record<string, number> = {
@@ -141,7 +163,14 @@ export function desiredMysqlTable(model: ModelDescriptor, variant: MysqlVariant)
141
163
  tableIndexes.push(...result);
142
164
  }
143
165
  }
144
- return { checks, columns, indexes: tableIndexes, model, name, notices };
166
+ const sync =
167
+ model.sync && model.fields[SYNC_SEQUENCE_FIELD]
168
+ ? {
169
+ insertTrigger: shortName(`lt_sync_${MYSQL_SYNC_TRIGGER_VERSION}`, name, ['bi']),
170
+ updateTrigger: shortName(`lt_sync_${MYSQL_SYNC_TRIGGER_VERSION}`, name, ['bu']),
171
+ }
172
+ : undefined;
173
+ return { checks, columns, indexes: tableIndexes, model, name, notices, ...(sync ? { sync } : {}) };
145
174
  }
146
175
 
147
176
  export function variantLabel(variant: MysqlVariant): string {
@@ -248,10 +277,14 @@ function desiredIndexes(
248
277
  }
249
278
  const unique = index.options.unique === true;
250
279
  const partial = index.options.partialFilterExpression as Record<string, unknown> | undefined;
280
+ // A synchronized model's unique index applies to live records only: it reads virtual live-key columns
281
+ const live = unique && !!partial && livePartial(partial, entries);
282
+ const liveSparse = live && Object.keys(partial!).length > 1;
251
283
  // A partial unique index whose filter only says "every indexed field exists" is a sparse one — MySQL's own NULL
252
284
  // handling. Any other filter has no counterpart, and a uniqueness checked nowhere must not pass for one that holds
253
- const sparse = index.options.sparse === true || (unique && !!partial && requiresIndexedFields(partial, entries));
254
- if (unique && partial && !sparse) {
285
+ const sparse =
286
+ !live && (index.options.sparse === true || (unique && !!partial && requiresIndexedFields(partial, entries)));
287
+ if (unique && partial && !sparse && !live) {
255
288
  throw new UnsupportedQueryError(
256
289
  `${model.name}: the partial unique index ${JSON.stringify(index.fields)} has no MySQL / MariaDB counterpart — ` +
257
290
  'uniqueness would be checked nowhere. Use a sparse unique index, or enforce it in the service',
@@ -273,7 +306,9 @@ function desiredIndexes(
273
306
  return 'an index over an array, JSON or binary field';
274
307
  }
275
308
  let target = column.name;
276
- if (unique && !sparse && !column.primaryKey && STRING_KINDS.has(field.kind) && !field.isArray) {
309
+ if (live && !column.primaryKey) {
310
+ target = liveKeyColumn(column, columns, !liveSparse && STRING_KINDS.has(field.kind) && !field.isArray);
311
+ } else if (unique && !sparse && !column.primaryKey && STRING_KINDS.has(field.kind) && !field.isArray) {
277
312
  // A missing value is a value of its own, as in MongoDB: the index reads it from a virtual column
278
313
  target = nullSentinelColumn(column, columns);
279
314
  }
@@ -331,6 +366,36 @@ function desiredIndexes(
331
366
  return result;
332
367
  }
333
368
 
369
+ /**
370
+ * Whether a partial filter is a synchronized model's live-record condition (`{ deletedAt: null }`), alone or with
371
+ * "every indexed field exists" — the rewrite of a sparse unique index (`sync-model.ts` → `liveUniqueIndex()`)
372
+ */
373
+ function livePartial(partial: Record<string, unknown>, entries: [string, unknown][]): boolean {
374
+ if (!(SYNC_DELETED_FIELD in partial) || partial[SYNC_DELETED_FIELD] !== null) {
375
+ return false;
376
+ }
377
+ const { [SYNC_DELETED_FIELD]: _live, ...rest } = partial;
378
+ return !Object.keys(rest).length || requiresIndexedFields(rest, entries);
379
+ }
380
+
381
+ /**
382
+ * The virtual column carrying `column`'s value while the record is live — and NULL for a tombstone, which a unique
383
+ * index lets repeat. `sentinel`: a live NULL becomes the null sentinel, so it collides like a second live value
384
+ */
385
+ function liveKeyColumn(column: MysqlDesiredColumn, columns: MysqlDesiredColumn[], sentinel: boolean): string {
386
+ const name = `${LIVE_KEY_PREFIX}${column.name}`.slice(0, 63);
387
+ if (!columns.some((candidate) => candidate.name === name)) {
388
+ const value = quoteMysqlIdentifier(column.name);
389
+ columns.push({
390
+ generated: `IF(${quoteMysqlIdentifier(columnName(SYNC_DELETED_FIELD))} IS NULL, ${sentinel ? `IFNULL(${value}, CHAR(0 USING utf8mb4))` : value}, NULL)`,
391
+ name,
392
+ primaryKey: false,
393
+ type: column.type,
394
+ });
395
+ }
396
+ return name;
397
+ }
398
+
334
399
  /** Whether a partial filter says exactly "every indexed field exists" (`{ field: { $exists: true } }` each) */
335
400
  function requiresIndexedFields(partial: Record<string, unknown>, entries: [string, unknown][]): boolean {
336
401
  const keys = Object.keys(partial);
@@ -384,6 +449,44 @@ export function mysqlColumnDefinition(column: MysqlDesiredColumn): string {
384
449
  return `${quoteMysqlIdentifier(column.name)} ${type}${collation}${key}${fallback}`;
385
450
  }
386
451
 
452
+ /**
453
+ * The counter table, created once for every synchronized table of the database. `triggers` records the version of the
454
+ * table's triggers once they are in place: the application account may not read the triggers themselves (that needs
455
+ * the TRIGGER privilege it must never hold), so a boot without the owner checks this column instead
456
+ */
457
+ export function mysqlSyncCountersStatement(): string {
458
+ return `CREATE TABLE IF NOT EXISTS ${quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS)} (\`model\` varchar(64) COLLATE utf8mb4_bin NOT NULL PRIMARY KEY, \`seq\` bigint NOT NULL, \`triggers\` varchar(16) COLLATE utf8mb4_bin NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`;
459
+ }
460
+
461
+ /**
462
+ * A synchronized table's counter row, its two triggers and the numbering of rows written before it was synchronized.
463
+ *
464
+ * A BEFORE ROW trigger runs after the row is locked, so a write takes its row first and the counter last; the counter
465
+ * row's lock is held until COMMIT, so sequence order is commit order (C1d). A value the statement sets itself is kept
466
+ * — the store's multi-row writes take their numbers and set them in their final statement. The numbering of existing
467
+ * rows runs in an explicit transaction holding the counter row: a DDL statement commits on its own, and a write firing
468
+ * the new trigger meanwhile must wait rather than draw a number twice.
469
+ */
470
+ export function mysqlSyncTableStatements(table: string, sync: MysqlDesiredSync): string[] {
471
+ const quoted = quoteMysqlIdentifier(table);
472
+ const counters = quoteMysqlIdentifier(MYSQL_SYNC_COUNTERS);
473
+ const key = mysqlLiteral(table);
474
+ const seq = quoteMysqlIdentifier(columnName(SYNC_SEQUENCE_FIELD));
475
+ const take = `UPDATE ${counters} SET \`seq\` = \`seq\` + 1 WHERE \`model\` = ${key}; SET NEW.${seq} = (SELECT \`seq\` FROM ${counters} WHERE \`model\` = ${key});`;
476
+ return [
477
+ `INSERT IGNORE INTO ${counters} (\`model\`, \`seq\`) VALUES (${key}, 0)`,
478
+ `DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(sync.insertTrigger)}`,
479
+ `DROP TRIGGER IF EXISTS ${quoteMysqlIdentifier(sync.updateTrigger)}`,
480
+ `CREATE TRIGGER ${quoteMysqlIdentifier(sync.insertTrigger)} BEFORE INSERT ON ${quoted} FOR EACH ROW BEGIN IF NEW.${seq} IS NULL THEN ${take} END IF; END`,
481
+ `CREATE TRIGGER ${quoteMysqlIdentifier(sync.updateTrigger)} BEFORE UPDATE ON ${quoted} FOR EACH ROW BEGIN IF NEW.${seq} <=> OLD.${seq} THEN ${take} END IF; END`,
482
+ 'START TRANSACTION',
483
+ `SELECT \`seq\` INTO @lt_sync_base FROM ${counters} WHERE \`model\` = ${key} FOR UPDATE`,
484
+ `UPDATE ${quoted} AS t JOIN (SELECT \`id\`, ROW_NUMBER() OVER (ORDER BY \`id\`) AS n FROM ${quoted} WHERE ${seq} IS NULL) AS o ON o.\`id\` = t.\`id\` SET t.${seq} = @lt_sync_base + o.n`,
485
+ `UPDATE ${counters} SET \`seq\` = GREATEST(\`seq\`, (SELECT COALESCE(MAX(${seq}), 0) FROM ${quoted})), \`triggers\` = ${mysqlLiteral(MYSQL_SYNC_TRIGGER_VERSION)} WHERE \`model\` = ${key}`,
486
+ 'COMMIT',
487
+ ];
488
+ }
489
+
387
490
  export function addMysqlCheckStatement(table: string, check: MysqlDesiredCheck): string {
388
491
  return `ALTER TABLE ${quoteMysqlIdentifier(table)} ADD CONSTRAINT ${quoteMysqlIdentifier(check.name)} CHECK (${check.expression})`;
389
492
  }