@lenne.tech/nest-server 12.1.1 → 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 (191) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/package-management.md +16 -1
  4. package/.claude/rules/persistence.md +26 -0
  5. package/.claude/rules/testing.md +79 -14
  6. package/CLAUDE.md +2 -1
  7. package/FRAMEWORK-API.md +17 -2
  8. package/README.md +18 -0
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  10. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  11. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  12. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  13. package/dist/core/common/persistence/data-source.runtime.js +4 -2
  14. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  15. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  16. package/dist/core/common/persistence/id-format.d.ts +2 -0
  17. package/dist/core/common/persistence/id-format.js +14 -0
  18. package/dist/core/common/persistence/id-format.js.map +1 -1
  19. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  20. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  21. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  22. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  23. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  24. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  25. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  26. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  27. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  28. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  29. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  30. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  31. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  32. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  33. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  34. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  35. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  36. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  37. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  38. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  39. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  40. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  41. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -4
  42. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  43. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  44. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  45. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  46. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  47. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  48. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  49. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  50. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  51. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  52. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  53. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  54. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  55. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  56. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  57. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  58. package/dist/core/common/persistence/sync-model.js +138 -0
  59. package/dist/core/common/persistence/sync-model.js.map +1 -0
  60. package/dist/core/common/services/crud.service.d.ts +13 -1
  61. package/dist/core/common/services/crud.service.js +174 -16
  62. package/dist/core/common/services/crud.service.js.map +1 -1
  63. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  64. package/dist/core/modules/error-code/error-codes.js +48 -0
  65. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  66. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  67. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  68. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  69. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  70. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  71. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  72. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  73. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  74. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  75. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  76. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  77. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  78. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  79. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  80. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  81. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  82. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  83. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  84. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  85. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  86. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  87. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  88. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  89. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  90. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  91. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  92. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  93. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  94. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  95. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  96. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  97. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  98. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  99. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  100. package/dist/core/modules/sync/core-sync.module.js +50 -0
  101. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  102. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  103. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  104. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  105. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  106. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  107. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  108. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  109. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  110. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  111. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  112. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  113. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  114. package/dist/core/modules/tenant/core-tenant.guard.js +4 -0
  115. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  116. package/dist/core.module.js +14 -0
  117. package/dist/core.module.js.map +1 -1
  118. package/dist/index.d.ts +16 -0
  119. package/dist/index.js +16 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  122. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  123. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  124. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  125. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  126. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  127. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  128. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  129. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  130. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  131. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  132. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  133. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  134. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  135. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  136. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  137. package/docs/REQUEST-LIFECYCLE.md +1 -0
  138. package/docs/offline-sync-protocol.md +96 -62
  139. package/docs/security-overrides.md +14 -0
  140. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  141. package/package.json +4 -2
  142. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  143. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  144. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  145. package/src/core/common/persistence/data-source.runtime.ts +5 -2
  146. package/src/core/common/persistence/data-store.interface.ts +65 -12
  147. package/src/core/common/persistence/id-format.ts +26 -0
  148. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  149. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  150. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  151. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  152. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  153. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  154. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  155. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  156. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
  157. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  158. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  159. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  160. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  161. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  162. package/src/core/common/persistence/sync-model.ts +270 -0
  163. package/src/core/common/services/crud.service.ts +257 -19
  164. package/src/core/modules/error-code/error-codes.ts +59 -0
  165. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  166. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  167. package/src/core/modules/sync/README.md +188 -0
  168. package/src/core/modules/sync/SECURITY.md +65 -0
  169. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  170. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  171. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  172. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  173. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  174. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  175. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  176. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  177. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  178. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  179. package/src/core/modules/sync/core-sync.module.ts +75 -0
  180. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  181. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  182. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  183. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  184. package/src/core/modules/tenant/core-tenant.guard.ts +8 -0
  185. package/src/core.module.ts +25 -0
  186. package/src/index.ts +21 -0
  187. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  188. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  189. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  190. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  191. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -3509,6 +3509,16 @@ export interface IServerOptions {
3509
3509
  path?: string;
3510
3510
  };
3511
3511
 
3512
+ /**
3513
+ * Offline sync (`docs/offline-sync-protocol.md`): `GET/POST /sync/{model}` for the models whose service carries
3514
+ * `@SyncService()` and whose model is declared with `@PersistedModel({ sync })`. Presence implies enabled — `true`
3515
+ * or `{}` enables it with the defaults, `{ enabled: false }` pre-configures without enabling. Absent: no route, no
3516
+ * store, no code path.
3517
+ *
3518
+ * @since 12.2.0
3519
+ */
3520
+ sync?: boolean | ISync;
3521
+
3512
3522
  /**
3513
3523
  * System setup configuration for initial admin creation.
3514
3524
  *
@@ -3831,6 +3841,125 @@ export interface IS3Config {
3831
3841
  stagingBucket?: string;
3832
3842
  }
3833
3843
 
3844
+ /**
3845
+ * Offline sync (`sync`, presence implies enabled). See `src/core/modules/sync/README.md`.
3846
+ *
3847
+ * @since 12.2.0
3848
+ */
3849
+ export interface ISync {
3850
+ /**
3851
+ * Secret the pull cursors are signed with (HMAC; the key is derived from it). Falls back to the
3852
+ * `SECRETS_ENCRYPTION_KEY` environment variable. REQUIRED in `production` / `staging` — the boot fails without it;
3853
+ * elsewhere an insecure development default is used with a warning. Changing it answers every client's next pull
3854
+ * with a full resync (`410 invalid`).
3855
+ */
3856
+ cursorSecret?: string;
3857
+
3858
+ /**
3859
+ * Pre-configure without enabling.
3860
+ * @default true (when the object is present)
3861
+ */
3862
+ enabled?: boolean;
3863
+
3864
+ /**
3865
+ * Hints that a synchronized model changed (protocol §9): SSE `GET /sync/hints` and the GraphQL subscription
3866
+ * `syncHint`, carrying `{ model, at }` only, to callers with the model's pull roles and — on a tenant-scoped model —
3867
+ * for changes in their own tenants. A hint only means "pull now": a client works without them. `false` switches both
3868
+ * channels off; with `redis` a hint reaches the subscribers of every replica.
3869
+ * @default on (both channels)
3870
+ */
3871
+ hints?:
3872
+ | boolean
3873
+ | {
3874
+ /**
3875
+ * At most one hint per model and tenant within this many milliseconds — a burst of writes is one hint.
3876
+ * @default 250
3877
+ */
3878
+ debounceMs?: number;
3879
+ /** @default true (when the object is present) */
3880
+ enabled?: boolean;
3881
+ /**
3882
+ * The GraphQL subscription `syncHint` (only where GraphQL is enabled).
3883
+ * @default true
3884
+ */
3885
+ graphql?: boolean;
3886
+ /**
3887
+ * The SSE stream `GET /sync/hints`.
3888
+ * @default true
3889
+ */
3890
+ sse?: boolean;
3891
+ };
3892
+
3893
+ /**
3894
+ * Route prefix of the sync endpoints.
3895
+ * @default 'sync'
3896
+ */
3897
+ path?: string;
3898
+
3899
+ pull?: {
3900
+ /**
3901
+ * Records per page when the client sends no `limit`.
3902
+ * @default 200
3903
+ */
3904
+ defaultLimit?: number;
3905
+ /**
3906
+ * Upper bound of the serialized size of one page; a page ends before it would exceed it (it always holds at least
3907
+ * one item when one is pending).
3908
+ * @default 1048576 (1 MiB)
3909
+ */
3910
+ maxBytes?: number;
3911
+ /**
3912
+ * Highest `limit` a client may ask for (at most 1000).
3913
+ * @default 1000
3914
+ */
3915
+ maxLimit?: number;
3916
+ /**
3917
+ * MongoDB only: the window W a pull run re-reads below its previous start (plan §3.4). Changes stamped within W
3918
+ * before a run started may be repeated; a write that takes longer than W from its stamp until it is visible would
3919
+ * be missed, so W must cover the longest such write.
3920
+ * @default replica set / sharded: the server's `transactionLifetimeLimitSeconds` (60 when unreadable) + 30;
3921
+ * standalone: 30
3922
+ */
3923
+ windowSeconds?: number;
3924
+ };
3925
+
3926
+ push?: {
3927
+ /**
3928
+ * Items per push request; more answer 400.
3929
+ * @default 100
3930
+ */
3931
+ maxItems?: number;
3932
+ };
3933
+
3934
+ /**
3935
+ * Per-user rate limits of pull and push (fixed window; exact across replicas when `redis` is configured). `false`
3936
+ * switches both off, `{ pull: false }` one of them.
3937
+ * @default pull 120 / 60 s, push 60 / 60 s
3938
+ */
3939
+ rateLimit?:
3940
+ | false
3941
+ | {
3942
+ pull?: false | { max?: number; windowSeconds?: number };
3943
+ push?: false | { max?: number; windowSeconds?: number };
3944
+ };
3945
+
3946
+ retention?: {
3947
+ /**
3948
+ * How long the outcome of a pushed mutation is kept, so a repeat is answered with it (protocol §6.5).
3949
+ * @default 30
3950
+ */
3951
+ mutationsDays?: number;
3952
+ /**
3953
+ * How long a tombstone is kept. Every hour (on one replica, under a lease) the purge first moves the model's
3954
+ * horizon to this age, then deletes the older tombstones for good; a client whose cursor is older than the horizon
3955
+ * answers its next pull with a full resync (`410 horizon`). `false` keeps tombstones for ever; an invalid value
3956
+ * (zero, negative, not a number) falls back to the default — never to "purge everything".
3957
+ * @default 90
3958
+ */
3959
+ tombstonesDays?: false | number;
3960
+ };
3961
+ }
3962
+
3834
3963
  export interface ISystemSetup {
3835
3964
  /**
3836
3965
  * Whether system setup is enabled.
@@ -4831,6 +4960,26 @@ export interface ICoreModuleOverrides {
4831
4960
  service?: Type<any>;
4832
4961
  };
4833
4962
 
4963
+ /**
4964
+ * Override the offline sync endpoints, the hint subscription and their service (`sync` config).
4965
+ *
4966
+ * - `controller` must extend `CoreSyncController` (e.g. to release a sync scope to API tokens)
4967
+ * - `hintsController` must extend `CoreSyncHintsController` (`GET /sync/hints`, e.g. for API tokens there too)
4968
+ * - `resolver` must extend `CoreSyncResolver` (the `syncHint` subscription)
4969
+ * - `service` must extend `CoreSyncService`
4970
+ *
4971
+ * @example
4972
+ * ```typescript
4973
+ * { sync: { controller: SyncController } }
4974
+ * ```
4975
+ */
4976
+ sync?: {
4977
+ controller?: Type<any>;
4978
+ hintsController?: Type<any>;
4979
+ resolver?: Type<any>;
4980
+ service?: Type<any>;
4981
+ };
4982
+
4834
4983
  /**
4835
4984
  * Override AI module collaborators with project-specific subclasses.
4836
4985
  *
@@ -32,6 +32,11 @@ export interface ServiceOptions {
32
32
  // If truly: create mode is activated
33
33
  create?: boolean;
34
34
 
35
+ // The id `create()` gives the new record: on a synchronized model the id a client chose offline, in the model's
36
+ // format (refused otherwise), on any other model a 24-character hex id. Passed beside the input because an input
37
+ // class keeps only the fields it declares: an `id` inside the input is lost the moment `inputType` maps it
38
+ createId?: string;
39
+
35
40
  // Current user to set ownership, check rights and other things
36
41
  currentUser?: {
37
42
  [key: string]: any;
@@ -42,9 +47,19 @@ export interface ServiceOptions {
42
47
  // Field selection for results (will be overwritten by populate if populate was set)
43
48
  fieldSelection?: FieldSelection;
44
49
 
50
+ // Conditional change of a synchronized model (`@PersistedModel({ sync })`): `update()` and `delete()` apply only
51
+ // when the stored record still has this version, in one atomic statement, and throw a 409 (`VersionConflictError`)
52
+ // otherwise. Refused on a model that is not synchronized
53
+ expectedVersion?: number;
54
+
45
55
  // Determines whether all restrictions are ignored
46
56
  force?: boolean;
47
57
 
58
+ // Read a synchronized model's tombstones too — its deleted records, which every other read leaves out (get, find,
59
+ // findOne, findAndCount, distinct). Reads only: update() and delete() never touch a tombstone and answer 404 for one.
60
+ // Applies to the main model only: a populated reference to a tombstone stays empty
61
+ includeTombstones?: boolean;
62
+
48
63
  // Overwrites type of input (array items)
49
64
  inputType?: new (...params: any[]) => any;
50
65
 
@@ -4,8 +4,15 @@ import { getConnectionToken, getModelToken } from './mongo/mongoose-compat.js';
4
4
  import type { Model } from 'mongoose';
5
5
 
6
6
  import { registerModelSchema } from './model-metadata.registry.js';
7
+ import { isDeclaredSchema } from './mongo/model-declarations.js';
8
+ import { applySyncSchema, assertSyncCollectionReady } from './mongo/sync-schema.plugin.js';
7
9
  import { MongooseDataStore } from './mongoose-data-store.js';
8
- import { getPersistedModelOptions, getPersistedModelOptionsByName } from './persisted-model.decorator.js';
10
+ import {
11
+ getPersistedModelOptions,
12
+ getPersistedModelOptionsByName,
13
+ getPersistedSyncByName,
14
+ } from './persisted-model.decorator.js';
15
+ import { resolveSyncOptions } from './sync-model.js';
9
16
  import {
10
17
  bindStoreToModel,
11
18
  getPersistenceRuntime,
@@ -46,7 +53,15 @@ export interface PersistenceModelDefinition extends ModelDefinition {
46
53
  export class CorePersistenceModule {
47
54
  static forFeature(models: PersistenceModelDefinition[]): DynamicModule {
48
55
  for (const model of models) {
49
- registerModelSchema(model.name, model.schema);
56
+ const sync = resolveSyncOptions(
57
+ model.model ? getPersistedModelOptions(model.model)?.sync : getPersistedSyncByName(model.name),
58
+ model.name,
59
+ );
60
+ // Described as declared first, then prepared: the descriptor must not see the Mongoose sync paths
61
+ registerModelSchema(model.name, model.schema, sync);
62
+ if (sync && !isDeclaredSchema(model.schema)) {
63
+ applySyncSchema(model.schema, sync);
64
+ }
50
65
  registerPersistedModel({
51
66
  declaredSource:
52
67
  model.dataSource ??
@@ -88,6 +103,10 @@ export class CorePersistenceModule {
88
103
  ? // No SQL source and no MongoDB connection: the project runs without a database (`database: false`)
89
104
  unavailableStore(model.name, 'the project runs without a database (`database: false`)')
90
105
  : new MongooseDataStore(mongooseModel);
106
+ // A synchronized model on MongoDB: the collection must hold nothing from before it was synchronized
107
+ if (store.capabilities.mongoosePopulate && store.capabilities.changeOrder) {
108
+ await assertSyncCollectionReady(mongooseModel);
109
+ }
91
110
  registerDataStore(model.name, store);
92
111
  bindStoreToModel(mongooseModel, store);
93
112
  // A stand-in already refuses, naming the cause; a real Mongoose model of a model routed elsewhere is made to
@@ -13,6 +13,7 @@ import {
13
13
  } from './data-source.config.js';
14
14
  import type { ModelDescriptor } from './model-descriptor.interface.js';
15
15
  import { getModelDescriptor } from './model-metadata.registry.js';
16
+ import { withSequenceField } from './sync-model.js';
16
17
  import { MongooseDataStore } from './mongoose-data-store.js';
17
18
  import {
18
19
  getPersistedModels,
@@ -190,10 +191,12 @@ export class DataSourceRuntime implements PersistenceRuntime {
190
191
  }
191
192
 
192
193
  private async openStore(source: ResolvedDataSource, name: string): Promise<DataStore<any>> {
193
- const model = getModelDescriptor(name);
194
- if (!model) {
194
+ const declared = getModelDescriptor(name);
195
+ if (!declared) {
195
196
  throw new Error(`No descriptor for model "${name}" — register it with CorePersistenceModule.forFeature()`);
196
197
  }
198
+ // A synchronized model's table also carries its commit-ordered change position (offline-sync plan §3.4)
199
+ const model = withSequenceField(declared);
197
200
  const app = await this.executor(source);
198
201
  const dialect = this.dialect(source);
199
202
  let changes: SchemaChange[];
@@ -54,6 +54,11 @@ export interface FindOptions {
54
54
  * Not combinable with `select`, as MongoDB does not combine them either.
55
55
  */
56
56
  exclude?: readonly string[];
57
+ /**
58
+ * Read tombstones too (a synchronized model's deleted records, `deletedAt` set). Every read of such a model leaves
59
+ * them out unless asked — only the sync module asks. No effect on other models.
60
+ */
61
+ includeTombstones?: boolean;
57
62
  /** Field names to return; the id is always returned */
58
63
  select?: readonly string[];
59
64
  skip?: number;
@@ -61,7 +66,15 @@ export interface FindOptions {
61
66
  sort?: Record<string, -1 | 1>;
62
67
  }
63
68
 
69
+ /** Options of a read that has no paging (`count`, `distinct`, `findById`) */
70
+ export interface ReadOptions {
71
+ /** See `FindOptions.includeTombstones` */
72
+ includeTombstones?: boolean;
73
+ }
74
+
64
75
  export interface UpdateOptions {
76
+ /** Match tombstones too (a synchronized model's deleted records) — see `FindOptions.includeTombstones` */
77
+ includeTombstones?: boolean;
65
78
  /**
66
79
  * `false` leaves the update time (`updatedAt`) as it is — for bookkeeping writes such as a last-used stamp, which
67
80
  * must not read as a change of the record (Mongoose's `timestamps: false`). @default true
@@ -69,6 +82,17 @@ export interface UpdateOptions {
69
82
  timestamps?: boolean;
70
83
  }
71
84
 
85
+ export interface DeleteOptions {
86
+ /**
87
+ * Remove the record for good. A synchronized model's delete otherwise leaves a tombstone (`deletedAt` set, the
88
+ * content cleared, the version incremented), which is how clients learn of it; a hard delete is the tombstone purge's
89
+ * (with `includeTombstones`). No effect on other models, whose delete always removes.
90
+ */
91
+ hardDelete?: boolean;
92
+ /** Match tombstones too — see `FindOptions.includeTombstones` */
93
+ includeTombstones?: boolean;
94
+ }
95
+
72
96
  export interface PushOptions {
73
97
  /** Insert position (MongoDB `$position`) */
74
98
  position?: number;
@@ -99,28 +123,57 @@ export interface StoreCapabilities {
99
123
  textSearch: boolean;
100
124
  /** Multi-statement transactions */
101
125
  transactions: boolean;
102
- /** Format of generated ids */
103
- idType: 'objectId';
126
+ /** Format of the model's ids — `uuidv7` only for a synchronized model that declares it */
127
+ idType: 'objectId' | 'uuidv7';
128
+ /**
129
+ * How a synchronized model orders its changes (offline-sync plan §3.4): `seq` — the commit-ordered `syncSeq` (SQL);
130
+ * `clock` — the server clock `syncAt`, read with a window (MongoDB). Absent for a model that is not synchronized.
131
+ */
132
+ changeOrder?: 'clock' | 'seq';
133
+ }
134
+
135
+ /** The database's clock as a pull over `changeOrder: 'clock'` reads it (offline-sync plan §3.4) */
136
+ export interface ChangeClock {
137
+ /** The database server's current time */
138
+ now: Date;
139
+ /**
140
+ * The window W: how long a write may take from its stamp until it is visible. A pull run starts this far below the
141
+ * previous run's start, so a change stamped before that start but committed after it is not passed.
142
+ */
143
+ windowMs: number;
104
144
  }
105
145
 
106
146
  export interface DataStore<T = Record<string, any>> {
107
147
  readonly capabilities: StoreCapabilities;
148
+ /**
149
+ * The database's clock and the window W of a synchronized model whose change order is the clock
150
+ * (`capabilities.changeOrder === 'clock'`); absent on every other store. What the sync module reads instead of
151
+ * asking for the driver.
152
+ */
153
+ changeClock?(): Promise<ChangeClock>;
108
154
  /** The database behind the store (`mongodb`, `postgres`, …) */
109
155
  readonly driver: string;
110
156
  readonly modelName: string;
111
157
 
112
- count(where?: Where<T>): Promise<number>;
113
- deleteById(id: string): Promise<null | StoredRecord<T>>;
114
- /** Remove every record matching `where`; returns how many were removed. */
115
- deleteMany(where: Where<T>): Promise<number>;
158
+ count(where?: Where<T>, options?: ReadOptions): Promise<number>;
159
+ /**
160
+ * Remove the record and return it as it was; `null` when there is none. On a synchronized model: turn it into a
161
+ * tombstone instead, unless `hardDelete` (see `DeleteOptions`).
162
+ */
163
+ deleteById(id: string, options?: DeleteOptions): Promise<null | StoredRecord<T>>;
164
+ /** Remove every record matching `where`; returns how many were removed (tombstoned on a synchronized model). */
165
+ deleteMany(where: Where<T>, options?: DeleteOptions): Promise<number>;
116
166
  /**
117
167
  * Remove the first record matching `where`, atomically (MongoDB's `findOneAndDelete`), and return it; `null` when
118
- * nothing matches.
168
+ * nothing matches (tombstoned on a synchronized model).
119
169
  */
120
- deleteOne(where: Where<T>): Promise<null | StoredRecord<T>>;
121
- distinct(field: string, where?: Where<T>): Promise<unknown[]>;
170
+ deleteOne(where: Where<T>, options?: DeleteOptions): Promise<null | StoredRecord<T>>;
171
+ distinct(field: string, where?: Where<T>, options?: ReadOptions): Promise<unknown[]>;
122
172
  find(where?: Where<T>, options?: FindOptions): Promise<StoredRecord<T>[]>;
123
- findById(id: string, options?: Pick<FindOptions, 'exclude' | 'select'>): Promise<null | StoredRecord<T>>;
173
+ findById(
174
+ id: string,
175
+ options?: Pick<FindOptions, 'exclude' | 'includeTombstones' | 'select'>,
176
+ ): Promise<null | StoredRecord<T>>;
124
177
  findOne(where: Where<T>, options?: Omit<FindOptions, 'limit' | 'skip'>): Promise<null | StoredRecord<T>>;
125
178
  insert(record: Partial<T>): Promise<StoredRecord<T>>;
126
179
  /**
@@ -145,11 +198,11 @@ export interface DataStore<T = Record<string, any>> {
145
198
  /** Apply `update` to the record and return it as stored afterwards; `null` when there is none. */
146
199
  updateById(id: string, update: DataStoreUpdate<T>, options?: UpdateOptions): Promise<null | StoredRecord<T>>;
147
200
  /** Apply `update` to every record matching `where`; returns how many records matched. */
148
- updateMany(where: Where<T>, update: DataStoreUpdate<T>): Promise<number>;
201
+ updateMany(where: Where<T>, update: DataStoreUpdate<T>, options?: UpdateOptions): Promise<number>;
149
202
  /**
150
203
  * Apply `update` to the first record matching `where`, atomically: the record still matches when it is changed, and
151
204
  * concurrent callers never change the same record on the strength of one match (MongoDB's `findOneAndUpdate`).
152
205
  * Returns the record as stored afterwards; `null` when nothing matches.
153
206
  */
154
- updateOne(where: Where<T>, update: DataStoreUpdate<T>): Promise<null | StoredRecord<T>>;
207
+ updateOne(where: Where<T>, update: DataStoreUpdate<T>, options?: UpdateOptions): Promise<null | StoredRecord<T>>;
155
208
  }
@@ -41,3 +41,29 @@ export function generateId(): string {
41
41
  bytes.writeUIntBE(counter, 9, 3);
42
42
  return bytes.toString('hex');
43
43
  }
44
+
45
+ /**
46
+ * A uuidv7 as the framework stores it (plan §3.5): lowercase, hyphenated, version nibble `7`, variant `8`–`b`. The id
47
+ * format a synchronized model may choose instead of the ObjectId (`@PersistedModel({ sync: { idFormat: 'uuidv7' } })`).
48
+ */
49
+ const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
50
+
51
+ /** Whether a value is a uuidv7 in the stored form — lowercase only, so one id has exactly one spelling */
52
+ export function isUuidV7(value: unknown): value is string {
53
+ return typeof value === 'string' && UUID_V7.test(value);
54
+ }
55
+
56
+ /**
57
+ * A new uuidv7 (RFC 9562): 48 bits of milliseconds, then 74 random bits — sortable by creation time like an ObjectId,
58
+ * but with nothing after the timestamp that one observed id would let anybody predict.
59
+ */
60
+ export function generateUuidV7(): string {
61
+ const bytes = randomBytes(16);
62
+ const millis = Date.now();
63
+ bytes.writeUIntBE(Math.floor(millis / 0x10000), 0, 4);
64
+ bytes.writeUInt16BE(millis % 0x10000, 4);
65
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
66
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
67
+ const hex = bytes.toString('hex');
68
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
69
+ }
@@ -77,12 +77,24 @@ export interface IndexDescriptor {
77
77
  options: Record<string, unknown>;
78
78
  }
79
79
 
80
+ /** The id format of a synchronized model: one per model, never mixed (offline-sync plan §3.5) */
81
+ export type SyncIdFormat = 'objectId' | 'uuidv7';
82
+
83
+ /** How a model is synchronized to offline clients — see `sync-model.ts` */
84
+ export interface SyncDescriptor {
85
+ idFormat: SyncIdFormat;
86
+ /** User-id fields a tombstone keeps besides `tenantId` */
87
+ scope: readonly string[];
88
+ }
89
+
80
90
  export interface ModelDescriptor {
81
91
  /** Explicit collection / table name, when the model declares one */
82
92
  collection?: string;
83
93
  fields: Record<string, FieldDescriptor>;
84
94
  indexes: IndexDescriptor[];
85
95
  name: string;
96
+ /** Present when the model is synchronized (`@PersistedModel({ sync })`); absent for every other model */
97
+ sync?: SyncDescriptor;
86
98
  /** Whether the model carries a `tenantId` — the same criterion the tenant plugin uses */
87
99
  tenantScoped: boolean;
88
100
  /** `createdAt` / `updatedAt` maintenance: `true`, `false`, or the configured field names */
@@ -2,6 +2,7 @@ import type { FieldDescriptor, FieldKind, IndexDescriptor, ModelDescriptor } fro
2
2
  import { declaredSchemaView } from './mongo/declared-schema.view.js';
3
3
  import { type DeclaredSchema, isDeclaredSchema } from './mongo/model-declarations.js';
4
4
  import { SchemaFactory } from './mongo/mongoose-compat.js';
5
+ import { type SyncDescriptor, withSyncDescriptor } from './sync-model.js';
5
6
 
6
7
  /**
7
8
  * Registry of database-neutral model descriptors.
@@ -70,12 +71,40 @@ export function describeModelClass(modelClass: abstract new (...args: any[]) =>
70
71
  return descriptor;
71
72
  }
72
73
 
74
+ /**
75
+ * The descriptor of a schema as it was declared, per schema object and model name. A synchronized model's Mongoose
76
+ * schema gains its system paths and partial indexes once it is registered (`mongo/sync-schema.plugin.ts`); a second
77
+ * registration of the same schema must describe what was declared, not what the plugin added.
78
+ */
79
+ const declaredDescriptors = new WeakMap<object, Map<string, ModelDescriptor>>();
80
+
73
81
  /**
74
82
  * Register the schema a model is persisted with, under its model name. A later registration under
75
83
  * the same name replaces the earlier one — the last `forFeature` wins, as it does in Mongoose.
84
+ *
85
+ * `sync` makes it the descriptor of a synchronized model (`sync-model.ts`).
76
86
  */
77
- export function registerModelSchema(name: string, schema: DeclaredSchema | SchemaLike): ModelDescriptor {
78
- const descriptor = describeSchema(name, schema);
87
+ export function registerModelSchema(
88
+ name: string,
89
+ schema: DeclaredSchema | SchemaLike,
90
+ sync?: SyncDescriptor,
91
+ ): ModelDescriptor {
92
+ let descriptor: ModelDescriptor;
93
+ if (sync) {
94
+ let byName = declaredDescriptors.get(schema);
95
+ if (!byName) {
96
+ byName = new Map();
97
+ declaredDescriptors.set(schema, byName);
98
+ }
99
+ let declared = byName.get(name);
100
+ if (!declared) {
101
+ declared = describeSchema(name, schema);
102
+ byName.set(name, declared);
103
+ }
104
+ descriptor = withSyncDescriptor(declared, sync);
105
+ } else {
106
+ descriptor = describeSchema(name, schema);
107
+ }
79
108
  descriptorsByName.set(name, descriptor);
80
109
  return descriptor;
81
110
  }