@rapidmx/activesync-plugin 1.0.0-beta.2 → 1.0.0-beta.3

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 (201) hide show
  1. package/README.md +4 -4
  2. package/dist/lib/BaseDeviceSyncStateRoute.js +32 -0
  3. package/dist/lib/BaseDeviceSyncStateRoute.js.map +1 -1
  4. package/dist/lib/BaseEasRoute.js +91 -15
  5. package/dist/lib/BaseEasRoute.js.map +1 -1
  6. package/dist/lib/EasAuditLog.js +56 -0
  7. package/dist/lib/EasAuditLog.js.map +1 -0
  8. package/dist/lib/EasCollectionLease.js +199 -0
  9. package/dist/lib/EasCollectionLease.js.map +1 -0
  10. package/dist/lib/EasCollectionStore.js +102 -0
  11. package/dist/lib/EasCollectionStore.js.map +1 -0
  12. package/dist/lib/EasCollectionSync.js +294 -0
  13. package/dist/lib/EasCollectionSync.js.map +1 -0
  14. package/dist/lib/EasSyncKeyUtils.js +125 -62
  15. package/dist/lib/EasSyncKeyUtils.js.map +1 -1
  16. package/dist/lib/MessageMoveRules.js +100 -0
  17. package/dist/lib/MessageMoveRules.js.map +1 -0
  18. package/dist/lib/MimeHeaderUtils.js +395 -0
  19. package/dist/lib/MimeHeaderUtils.js.map +1 -0
  20. package/dist/lib/RegexPatternUtils.js +30 -0
  21. package/dist/lib/RegexPatternUtils.js.map +1 -0
  22. package/dist/lib/RestapiCompat.js +41 -0
  23. package/dist/lib/RestapiCompat.js.map +1 -0
  24. package/dist/lib/adapters/CalendarSyncAdapter.js +142 -33
  25. package/dist/lib/adapters/CalendarSyncAdapter.js.map +1 -1
  26. package/dist/lib/adapters/EmailSyncAdapter.js +114 -27
  27. package/dist/lib/adapters/EmailSyncAdapter.js.map +1 -1
  28. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js +2 -1
  29. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js.map +1 -1
  30. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js +2 -1
  31. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js.map +1 -1
  32. package/dist/lib/codec/WbxmlDecoder.js +86 -34
  33. package/dist/lib/codec/WbxmlDecoder.js.map +1 -1
  34. package/dist/lib/codec/WbxmlEncoder.js +103 -22
  35. package/dist/lib/codec/WbxmlEncoder.js.map +1 -1
  36. package/dist/lib/commands/ComposeMailCommand.js +105 -31
  37. package/dist/lib/commands/ComposeMailCommand.js.map +1 -1
  38. package/dist/lib/commands/FolderSyncCommand.js +67 -27
  39. package/dist/lib/commands/FolderSyncCommand.js.map +1 -1
  40. package/dist/lib/commands/GetItemEstimateCommand.js +80 -59
  41. package/dist/lib/commands/GetItemEstimateCommand.js.map +1 -1
  42. package/dist/lib/commands/ItemOperationsCommand.js +219 -56
  43. package/dist/lib/commands/ItemOperationsCommand.js.map +1 -1
  44. package/dist/lib/commands/MeetingResponseCommand.js +212 -53
  45. package/dist/lib/commands/MeetingResponseCommand.js.map +1 -1
  46. package/dist/lib/commands/MoveItemsCommand.js +54 -21
  47. package/dist/lib/commands/MoveItemsCommand.js.map +1 -1
  48. package/dist/lib/commands/PingCommand.js +288 -56
  49. package/dist/lib/commands/PingCommand.js.map +1 -1
  50. package/dist/lib/commands/ProvisionCommand.js +34 -17
  51. package/dist/lib/commands/ProvisionCommand.js.map +1 -1
  52. package/dist/lib/commands/ResolveRecipientsCommand.js +43 -5
  53. package/dist/lib/commands/ResolveRecipientsCommand.js.map +1 -1
  54. package/dist/lib/commands/SearchCommand.js +92 -17
  55. package/dist/lib/commands/SearchCommand.js.map +1 -1
  56. package/dist/lib/commands/SettingsCommand.js +2 -1
  57. package/dist/lib/commands/SettingsCommand.js.map +1 -1
  58. package/dist/lib/commands/SmartForwardCommand.js +2 -1
  59. package/dist/lib/commands/SmartForwardCommand.js.map +1 -1
  60. package/dist/lib/commands/SmartReplyCommand.js +2 -1
  61. package/dist/lib/commands/SmartReplyCommand.js.map +1 -1
  62. package/dist/lib/commands/SyncCommand.js +481 -244
  63. package/dist/lib/commands/SyncCommand.js.map +1 -1
  64. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js +6 -1
  65. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js.map +1 -1
  66. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js +3 -1
  67. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js.map +1 -1
  68. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js +2 -1
  69. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js.map +1 -1
  70. package/dist/lib/commands/mongo/PingCommandMongo.js +23 -0
  71. package/dist/lib/commands/mongo/PingCommandMongo.js.map +1 -0
  72. package/dist/lib/commands/mongo/SearchCommandMongo.js +3 -1
  73. package/dist/lib/commands/mongo/SearchCommandMongo.js.map +1 -1
  74. package/dist/lib/commands/mongo/SendMailCommandMongo.js +2 -1
  75. package/dist/lib/commands/mongo/SendMailCommandMongo.js.map +1 -1
  76. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js +2 -1
  77. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js.map +1 -1
  78. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js +2 -1
  79. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js.map +1 -1
  80. package/dist/lib/commands/mongo/SyncCommandMongo.js +7 -1
  81. package/dist/lib/commands/mongo/SyncCommandMongo.js.map +1 -1
  82. package/dist/lib/commands/mongo/index.js +1 -0
  83. package/dist/lib/commands/mongo/index.js.map +1 -1
  84. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js +6 -1
  85. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js.map +1 -1
  86. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js +3 -1
  87. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js.map +1 -1
  88. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js +2 -1
  89. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js.map +1 -1
  90. package/dist/lib/commands/sql/PingCommandSQL.js +23 -0
  91. package/dist/lib/commands/sql/PingCommandSQL.js.map +1 -0
  92. package/dist/lib/commands/sql/SearchCommandSQL.js +3 -1
  93. package/dist/lib/commands/sql/SearchCommandSQL.js.map +1 -1
  94. package/dist/lib/commands/sql/SendMailCommandSQL.js +2 -1
  95. package/dist/lib/commands/sql/SendMailCommandSQL.js.map +1 -1
  96. package/dist/lib/commands/sql/SmartForwardCommandSQL.js +2 -1
  97. package/dist/lib/commands/sql/SmartForwardCommandSQL.js.map +1 -1
  98. package/dist/lib/commands/sql/SmartReplyCommandSQL.js +2 -1
  99. package/dist/lib/commands/sql/SmartReplyCommandSQL.js.map +1 -1
  100. package/dist/lib/commands/sql/SyncCommandSQL.js +7 -1
  101. package/dist/lib/commands/sql/SyncCommandSQL.js.map +1 -1
  102. package/dist/lib/commands/sql/index.js +1 -0
  103. package/dist/lib/commands/sql/index.js.map +1 -1
  104. package/dist/lib/index.js +5 -0
  105. package/dist/lib/index.js.map +1 -1
  106. package/dist/lib/jobs/EasDeviceStateCleanupJob.js +44 -2
  107. package/dist/lib/jobs/EasDeviceStateCleanupJob.js.map +1 -1
  108. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js +4 -0
  109. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js.map +1 -1
  110. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js +9 -0
  111. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js.map +1 -1
  112. package/dist/lib/models/EasCollectionChunk.js +2 -0
  113. package/dist/lib/models/EasCollectionChunk.js.map +1 -0
  114. package/dist/lib/models/EasCollectionState.js +2 -0
  115. package/dist/lib/models/EasCollectionState.js.map +1 -0
  116. package/dist/lib/models/mongo/DeviceSyncStateMongo.js +7 -0
  117. package/dist/lib/models/mongo/DeviceSyncStateMongo.js.map +1 -1
  118. package/dist/lib/models/mongo/EasCollectionChunkMongo.js +83 -0
  119. package/dist/lib/models/mongo/EasCollectionChunkMongo.js.map +1 -0
  120. package/dist/lib/models/mongo/EasCollectionStateMongo.js +162 -0
  121. package/dist/lib/models/mongo/EasCollectionStateMongo.js.map +1 -0
  122. package/dist/lib/models/sql/DeviceSyncStateSQL.js +7 -0
  123. package/dist/lib/models/sql/DeviceSyncStateSQL.js.map +1 -1
  124. package/dist/lib/models/sql/EasCollectionChunkSQL.js +83 -0
  125. package/dist/lib/models/sql/EasCollectionChunkSQL.js.map +1 -0
  126. package/dist/lib/models/sql/EasCollectionStateSQL.js +162 -0
  127. package/dist/lib/models/sql/EasCollectionStateSQL.js.map +1 -0
  128. package/dist/lib/mongo/EasRouteMongo.js +2 -2
  129. package/dist/lib/mongo/EasRouteMongo.js.map +1 -1
  130. package/dist/lib/mongo.js +2 -0
  131. package/dist/lib/mongo.js.map +1 -1
  132. package/dist/lib/sql/EasRouteSQL.js +2 -2
  133. package/dist/lib/sql/EasRouteSQL.js.map +1 -1
  134. package/dist/lib/sql.js +2 -0
  135. package/dist/lib/sql.js.map +1 -1
  136. package/dist/types/BaseDeviceSyncStateRoute.d.ts +10 -0
  137. package/dist/types/BaseEasRoute.d.ts +15 -4
  138. package/dist/types/EasAuditLog.d.ts +47 -0
  139. package/dist/types/EasCollectionLease.d.ts +60 -0
  140. package/dist/types/EasCollectionStore.d.ts +51 -0
  141. package/dist/types/EasCollectionSync.d.ts +96 -0
  142. package/dist/types/EasCommandHandler.d.ts +7 -4
  143. package/dist/types/EasSyncKeyUtils.d.ts +73 -42
  144. package/dist/types/MessageMoveRules.d.ts +45 -0
  145. package/dist/types/MimeHeaderUtils.d.ts +73 -0
  146. package/dist/types/RegexPatternUtils.d.ts +14 -0
  147. package/dist/types/RestapiCompat.d.ts +24 -0
  148. package/dist/types/adapters/CalendarSyncAdapter.d.ts +47 -18
  149. package/dist/types/adapters/EasCollectionSyncAdapter.d.ts +18 -2
  150. package/dist/types/adapters/EmailSyncAdapter.d.ts +24 -7
  151. package/dist/types/adapters/mongo/EmailSyncAdapterMongo.d.ts +1 -0
  152. package/dist/types/adapters/sql/EmailSyncAdapterSQL.d.ts +1 -0
  153. package/dist/types/codec/WbxmlDecoder.d.ts +39 -0
  154. package/dist/types/codec/WbxmlEncoder.d.ts +36 -1
  155. package/dist/types/commands/ComposeMailCommand.d.ts +33 -15
  156. package/dist/types/commands/FolderSyncCommand.d.ts +4 -1
  157. package/dist/types/commands/GetItemEstimateCommand.d.ts +20 -15
  158. package/dist/types/commands/ItemOperationsCommand.d.ts +29 -4
  159. package/dist/types/commands/MeetingResponseCommand.d.ts +52 -13
  160. package/dist/types/commands/MoveItemsCommand.d.ts +13 -3
  161. package/dist/types/commands/PingCommand.d.ts +72 -14
  162. package/dist/types/commands/ProvisionCommand.d.ts +16 -11
  163. package/dist/types/commands/ResolveRecipientsCommand.d.ts +4 -0
  164. package/dist/types/commands/SearchCommand.d.ts +13 -1
  165. package/dist/types/commands/SyncCommand.d.ts +102 -62
  166. package/dist/types/commands/mongo/GetItemEstimateCommandMongo.d.ts +3 -0
  167. package/dist/types/commands/mongo/ItemOperationsCommandMongo.d.ts +2 -0
  168. package/dist/types/commands/mongo/MeetingResponseCommandMongo.d.ts +1 -0
  169. package/dist/types/commands/mongo/PingCommandMongo.d.ts +8 -0
  170. package/dist/types/commands/mongo/SearchCommandMongo.d.ts +2 -0
  171. package/dist/types/commands/mongo/SendMailCommandMongo.d.ts +1 -0
  172. package/dist/types/commands/mongo/SmartForwardCommandMongo.d.ts +1 -0
  173. package/dist/types/commands/mongo/SmartReplyCommandMongo.d.ts +1 -0
  174. package/dist/types/commands/mongo/SyncCommandMongo.d.ts +4 -0
  175. package/dist/types/commands/mongo/index.d.ts +1 -0
  176. package/dist/types/commands/sql/GetItemEstimateCommandSQL.d.ts +3 -0
  177. package/dist/types/commands/sql/ItemOperationsCommandSQL.d.ts +2 -0
  178. package/dist/types/commands/sql/MeetingResponseCommandSQL.d.ts +1 -0
  179. package/dist/types/commands/sql/PingCommandSQL.d.ts +8 -0
  180. package/dist/types/commands/sql/SearchCommandSQL.d.ts +2 -0
  181. package/dist/types/commands/sql/SendMailCommandSQL.d.ts +1 -0
  182. package/dist/types/commands/sql/SmartForwardCommandSQL.d.ts +1 -0
  183. package/dist/types/commands/sql/SmartReplyCommandSQL.d.ts +1 -0
  184. package/dist/types/commands/sql/SyncCommandSQL.d.ts +4 -0
  185. package/dist/types/commands/sql/index.d.ts +1 -0
  186. package/dist/types/index.d.ts +5 -0
  187. package/dist/types/jobs/EasDeviceStateCleanupJob.d.ts +15 -0
  188. package/dist/types/jobs/mongo/EasDeviceStateCleanupJobMongo.d.ts +2 -0
  189. package/dist/types/jobs/sql/EasDeviceStateCleanupJobSQL.d.ts +4 -0
  190. package/dist/types/models/DeviceSyncState.d.ts +13 -2
  191. package/dist/types/models/EasCollectionChunk.d.ts +20 -0
  192. package/dist/types/models/EasCollectionState.d.ts +79 -0
  193. package/dist/types/models/mongo/DeviceSyncStateMongo.d.ts +1 -0
  194. package/dist/types/models/mongo/EasCollectionChunkMongo.d.ts +16 -0
  195. package/dist/types/models/mongo/EasCollectionStateMongo.d.ts +27 -0
  196. package/dist/types/models/sql/DeviceSyncStateSQL.d.ts +1 -0
  197. package/dist/types/models/sql/EasCollectionChunkSQL.d.ts +16 -0
  198. package/dist/types/models/sql/EasCollectionStateSQL.d.ts +27 -0
  199. package/dist/types/mongo.d.ts +2 -0
  200. package/dist/types/sql.d.ts +2 -0
  201. package/package.json +267 -238
@@ -1,6 +1,34 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiError } from "@rapidrest/core";
6
+ import { ApiErrors } from "@rapidrest/service-core";
7
+ import { asEntity } from "./RestapiCompat.js";
8
+ /** Whether `uid` can be listed in an `in(...)` query operand as itself: no comma (the operand separator), no parentheses
9
+ * or other punctuation, and not one of the literals the query parser substitutes (`me`, `null`). */
10
+ export function isListableUid(uid) {
11
+ return /^[A-Za-z0-9_.:@+-]+$/.test(uid) && uid !== "me" && uid !== "null";
12
+ }
13
+ /** The cursor that precedes every row ever written. */
14
+ export function epochCursor() {
15
+ return { date: new Date(0), uid: "" };
16
+ }
17
+ /** Orders two `(date, uid)` positions: negative when `a` sorts before `b`, positive when after, `0` when equal. */
18
+ export function compareCursor(a, b) {
19
+ const byDate = a.date.getTime() - b.date.getTime();
20
+ if (byDate !== 0) {
21
+ return byDate;
22
+ }
23
+ return a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0;
24
+ }
25
+ /** The cursor position of `row`. */
26
+ export function cursorOf(row) {
27
+ return { date: new Date(row.dateModified), uid: row.uid };
28
+ }
1
29
  /** Formats a `SyncKey` back into its wire string form. */
2
30
  export function formatSyncKey(key) {
3
- return `${key.generation}:${key.watermark.toISOString()}`;
31
+ return `${key.generation}:${key.watermark.toISOString()}${key.uid ? `#${key.uid}` : ""}`;
4
32
  }
5
33
  /** Parses a stored/previously-issued `SyncKey` string. Returns `undefined` for a malformed value - callers
6
34
  * only ever parse a key this library itself minted and stored (never a raw, unvalidated client value; see
@@ -11,15 +39,17 @@ export function parseSyncKey(value) {
11
39
  return undefined;
12
40
  }
13
41
  const generation = Number(value.slice(0, separator));
14
- const watermark = new Date(value.slice(separator + 1));
42
+ const rest = value.slice(separator + 1);
43
+ const hash = rest.indexOf("#");
44
+ const watermark = new Date(hash === -1 ? rest : rest.slice(0, hash));
15
45
  if (!Number.isFinite(generation) || Number.isNaN(watermark.getTime())) {
16
46
  return undefined;
17
47
  }
18
- return { generation, watermark };
48
+ return hash === -1 ? { generation, watermark } : { generation, watermark, uid: rest.slice(hash + 1) };
19
49
  }
20
50
  /**
21
51
  * Resolves an incoming client `SyncKey` string against the value this library itself previously issued and
22
- * stored (`storedValue`, e.g. `DeviceSyncState.folderSyncKeys[folderUid]`) — deliberately a plain string
52
+ * stored (`storedValue`, e.g. `DeviceSyncState.folderSyncKeys["$foldersync"]`) — deliberately a plain string
23
53
  * equality check, not a re-parse-and-compare of the client's value, so a client that echoes back anything
24
54
  * other than the exact opaque string it was handed is treated as `"invalid"` even if it happens to parse.
25
55
  *
@@ -40,80 +70,113 @@ export function resolveSyncKey(clientValue, storedValue) {
40
70
  const parsed = parseSyncKey(storedValue);
41
71
  return parsed ? { kind: "valid", key: parsed } : { kind: "invalid" };
42
72
  }
73
+ /** How many times `persistDeviceSyncState` re-reads and re-applies its patch after losing an optimistic-lock race. */
74
+ const PERSIST_MAX_ATTEMPTS = 5;
43
75
  /**
44
76
  * Applies `patch` to `deviceSyncState` and persists it - the one correct way any EAS command handler (or
45
- * `BaseEasRoute` itself) should ever write to a `DeviceSyncState`, replacing the inline
46
- * `deviceSyncStateRepo.update(...)` calls `ProvisionCommand`/`FolderSyncCommand`/`SyncCommand` used to each
47
- * repeat individually.
77
+ * `BaseEasRoute` itself) should ever write to a `DeviceSyncState`.
48
78
  *
49
- * This exists because `RepoUtils.update()` does **not** mutate the `existing` object passed to it - confirmed
50
- * by reading its implementation directly - it only returns a freshly-fetched instance reflecting the write,
51
- * leaving the caller's in-memory copy (including its optimistic-concurrency `version`) stale. A single write
52
- * per request is harmless (nothing re-reads the stale copy), but a *second* write to the same `deviceSyncState`
53
- * later in the same request - e.g. `BaseEasRoute.dispatch()`'s own trailing `lastSyncAt` update, or a second
54
- * collection's SyncKey in a multi-collection `Sync` - would build its own patch's `version` off that stale
55
- * value. `RepoUtils.update()` filters its underlying write by exactly that `version`, so the write silently
56
- * matches zero rows instead of throwing - a silent no-op, not a visible error. Copying `update()`'s own
57
- * returned result back onto `deviceSyncState` (last line below) keeps every field, including `version`, current
58
- * for whatever writes this same request still has left to make.
79
+ * `RepoUtils.update()` does **not** mutate the `existing` object passed to it - it only returns a freshly-fetched
80
+ * instance reflecting the write - so the returned row (including its bumped `version`) is copied back onto
81
+ * `deviceSyncState`, keeping a second write later in the same request from being built off a stale version.
82
+ *
83
+ * **Concurrent requests**: one device routinely has several requests in flight at once (a long-poll `Ping`
84
+ * alongside a `Sync`, or two `Sync`s for different folders), and the row's optimistic lock covers the whole row.
85
+ * Losing that race used to surface as a 409 *after* the command's side effects had already happened. Instead, a
86
+ * version conflict re-reads the row, re-applies the patch on top of the fresh copy and retries (bounded). Pass
87
+ * `patch` as a function when it derives from the row's current content (e.g. updating one entry of the
88
+ * `folderSyncKeys` map) so the retry merges into what the other request wrote rather than overwriting it.
59
89
  */
60
90
  export async function persistDeviceSyncState(deviceSyncState, deviceSyncStateRepo, patch) {
61
- Object.assign(deviceSyncState, patch);
62
- const updated = await deviceSyncStateRepo.update({ uid: deviceSyncState.uid, version: deviceSyncState.version, ...patch }, deviceSyncState, { ignoreACL: true, skipPush: true });
63
- Object.assign(deviceSyncState, updated);
91
+ for (let attempt = 1;; attempt++) {
92
+ const values = typeof patch === "function" ? patch(deviceSyncState) : patch;
93
+ Object.assign(deviceSyncState, values);
94
+ try {
95
+ const updated = await deviceSyncStateRepo.update({ uid: deviceSyncState.uid, version: deviceSyncState.version, ...values }, asEntity(deviceSyncStateRepo, deviceSyncState), { ignoreACL: true, skipPush: true });
96
+ Object.assign(deviceSyncState, updated);
97
+ return;
98
+ }
99
+ catch (err) {
100
+ if (!(err instanceof ApiError) || err.code !== ApiErrors.INVALID_OBJECT_VERSION || attempt >= PERSIST_MAX_ATTEMPTS) {
101
+ throw err;
102
+ }
103
+ const fresh = await deviceSyncStateRepo.findOne(deviceSyncState.uid, { ignoreACL: true });
104
+ if (!fresh) {
105
+ throw err;
106
+ }
107
+ Object.assign(deviceSyncState, fresh);
108
+ }
109
+ }
64
110
  }
65
- /** A tolerance window for treating a row's `dateCreated`/`dateModified` as "close enough" to be an `Add`
66
- * rather than a `Change` - the two are set within the same request handler and so are typically only
67
- * milliseconds apart, never exactly equal down to the microsecond depending on the datastore's clock
68
- * resolution. */
69
- const NEWLY_CREATED_TOLERANCE_MS = 1000;
111
+ /** The JSON sort expression both backends' query builders accept for a `(dateModified, uid)` ordering. */
112
+ const CURSOR_SORT = JSON.stringify({ dateModified: "ASC", uid: "ASC" });
70
113
  /**
71
- * Enumerates `Add`/`Change`/`Delete`s for one scoped collection since `watermark`, for `FolderSyncCommand` and
72
- * (eventually) `SyncCommand`'s shared cursor mechanism. Requires `T` to be `RecoverableBaseEntity` (soft
73
- * delete) — see `RecoverableRepoUtils`'s own doc comment for why a plain hard-deleted entity can't support
74
- * this at all (nothing left to enumerate once a row is actually gone).
114
+ * Reads up to `limit` rows matching `criteria` that sort strictly after `cursor` in `(dateModified, uid)` order,
115
+ * live and soft-deleted rows merged into one stream. `more` is `true` when rows beyond the returned page exist.
75
116
  *
76
- * `RepoUtils.find()` does **not** support an `includeDeleted` option - confirmed by reading its source: unlike
77
- * `count()`/`findOne()`, it never strips `ModelUtils.buildSearchQuery()`'s default `deleted: false` exclusion
78
- * back out. Rather than the (harmless-looking but silently no-op) `includeDeleted: true` this function used to
79
- * pass, deleted rows are fetched via a **second** `find()` call with an explicit, literal `deleted: true` in
80
- * the query object itself - `buildSearchQuery()`'s exclusion only applies when the caller's query has no
81
- * `"deleted"` key at all, so supplying one directly (rather than relying on an option `find()` doesn't honor)
82
- * reliably selects exactly the soft-deleted rows instead. The two result sets are then merged and re-windowed
83
- * together so `windowSize`/`MoreAvailable` still describe the combined stream, not each half independently.
117
+ * `RepoUtils.find()` excludes soft-deleted rows unless the query names `deleted` explicitly, so the two halves are
118
+ * separate queries (the second with a literal `deleted: true`), each over-fetched by one and merged. `limit`/`sort`
119
+ * are baked into the query object as well as the options - the SQL query builder only reads the former.
84
120
  */
85
- export async function computeChanges(repo, scopeField, scopeUid, watermark, windowSize) {
86
- // Overfetch by one (per stream) so a full window can be distinguished from an exact-fit window without a
87
- // separate count() round trip - the same trick `ExternalShareExpirationJob`/`MailboxQuotaRecalcJob` already
88
- // use for their own MoreAvailable-shaped bookkeeping. `limit`/`sort` must be baked into the query object
89
- // itself (not just `options`) for the SQL backend - see those same jobs' comments on this exact requirement.
90
- const baseCriteria = { [scopeField]: scopeUid, dateModified: `gt(${watermark.toISOString()})` };
91
- const findOptions = { ignoreACL: true, limit: windowSize + 1, sort: "dateModified" };
92
- const [liveRows, deletedRows] = await Promise.all([
93
- repo.find({ ...baseCriteria, sort: "dateModified", limit: windowSize + 1 }, findOptions),
94
- repo.find({ ...baseCriteria, deleted: true, sort: "dateModified", limit: windowSize + 1 }, findOptions),
95
- ]);
96
- const merged = [...liveRows, ...deletedRows].sort((a, b) => new Date(a.dateModified).getTime() - new Date(b.dateModified).getTime());
97
- const moreAvailable = merged.length > windowSize;
98
- const page = moreAvailable ? merged.slice(0, windowSize) : merged;
121
+ export async function scanAfter(repo, criteria, cursor, limit) {
122
+ const iso = cursor.date.toISOString();
123
+ const position = cursor.uid
124
+ ? { $or: [{ dateModified: `gt(${iso})` }, { dateModified: `range(${iso},${iso})`, uid: `gt(${cursor.uid})` }] }
125
+ : { dateModified: `gt(${iso})` };
126
+ const query = { ...criteria, ...position, sort: CURSOR_SORT, limit: limit + 1 };
127
+ const options = { ignoreACL: true, limit: limit + 1 };
128
+ const [live, deleted] = await Promise.all([repo.find(query, options), repo.find({ ...query, deleted: true }, options)]);
129
+ const merged = [...live, ...deleted].sort((a, b) => compareCursor(cursorOf(a), cursorOf(b)));
130
+ return { rows: merged.slice(0, limit), more: merged.length > limit };
131
+ }
132
+ /** The JSON sort expression for the newest-first `(dateModified, uid)` ordering `scanOverlap` reads with. */
133
+ const CURSOR_SORT_DESC = JSON.stringify({ dateModified: "DESC", uid: "DESC" });
134
+ /**
135
+ * Reads the rows matching `criteria` at or before `cursor` whose `dateModified` lies within `windowMs` of it - the
136
+ * part of the stream a cursor has already passed but where a row committed out of order (another replica stamped
137
+ * `dateModified` before this one read past it, but committed after) can still appear. At most `limit` rows, the
138
+ * newest ones, returned in ascending `(dateModified, uid)` order; live and soft-deleted rows merged as `scanAfter`
139
+ * does. Returns nothing for the epoch cursor (nothing has been passed yet).
140
+ */
141
+ export async function scanOverlap(repo, criteria, cursor, windowMs, limit) {
142
+ if (cursor.date.getTime() === 0 || windowMs <= 0 || limit <= 0) {
143
+ return [];
144
+ }
145
+ const from = new Date(Math.max(0, cursor.date.getTime() - windowMs)).toISOString();
146
+ const query = { ...criteria, dateModified: `range(${from},${cursor.date.toISOString()})`, sort: CURSOR_SORT_DESC, limit };
147
+ const options = { ignoreACL: true, limit };
148
+ const [live, deleted] = await Promise.all([repo.find(query, options), repo.find({ ...query, deleted: true }, options)]);
149
+ return [...live, ...deleted]
150
+ .filter((row) => compareCursor(cursorOf(row), cursor) <= 0)
151
+ .sort((a, b) => compareCursor(cursorOf(b), cursorOf(a)))
152
+ .slice(0, limit)
153
+ .reverse();
154
+ }
155
+ /**
156
+ * Enumerates `Add`/`Change`/`Delete`s for one scoped collection after `cursor` - used by `FolderSyncCommand`
157
+ * (scoped by `mailboxUid` over `Folder`). `Sync` item collections use `EasCollectionSync` instead, which tracks the
158
+ * exact set of items each device holds; the folder hierarchy is small and always fully re-sent on `SyncKey 0`, so
159
+ * a creation-time rule is enough here: a row created after the cursor can't be on the device yet, so a live one is
160
+ * an `Add` (never an `Update` for a `ServerId` the device has never seen) and a deleted one is not reported at all.
161
+ */
162
+ export async function computeChanges(repo, scopeField, scopeUid, cursor, windowSize) {
163
+ const { rows, more } = await scanAfter(repo, { [scopeField]: scopeUid }, cursor, windowSize);
99
164
  const adds = [];
100
165
  const changes = [];
101
166
  const deletes = [];
102
- let newWatermark = watermark;
103
- for (const row of page) {
104
- const dateModified = new Date(row.dateModified);
105
- if (dateModified.getTime() > newWatermark.getTime()) {
106
- newWatermark = dateModified;
107
- }
167
+ let next = cursor;
168
+ for (const row of rows) {
169
+ next = cursorOf(row);
170
+ const createdAfterCursor = new Date(row.dateCreated).getTime() > cursor.date.getTime();
108
171
  if (row.deleted === true) {
109
- deletes.push(row);
172
+ if (!createdAfterCursor) {
173
+ deletes.push(row);
174
+ }
110
175
  }
111
176
  else {
112
- const dateCreated = new Date(row.dateCreated);
113
- const isNewlyCreated = Math.abs(dateModified.getTime() - dateCreated.getTime()) < NEWLY_CREATED_TOLERANCE_MS;
114
- (isNewlyCreated ? adds : changes).push(row);
177
+ (createdAfterCursor ? adds : changes).push(row);
115
178
  }
116
179
  }
117
- return { adds, changes, deletes, newWatermark, moreAvailable };
180
+ return { adds, changes, deletes, cursor: next, moreAvailable: more };
118
181
  }
119
182
  //# sourceMappingURL=EasSyncKeyUtils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"EasSyncKeyUtils.js","sourceRoot":"","sources":["../../src/EasSyncKeyUtils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"EasSyncKeyUtils.js","sourceRoot":"","sources":["../../src/EasSyncKeyUtils.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { FolderType } from "@rapidmx/restapi";
6
+ /** `ScheduledSendJob`'s per-send retry state (restapi source), cleared with `scheduledSendTime` when a queued message
7
+ * leaves Outbox. Not all of these exist in `@rapidmx/restapi` 0.9.0 - see `planMessageMove()`. */
8
+ const SCHEDULED_SEND_RETRY_FIELDS = ["scheduledSendAttempts", "scheduledSendError", "scheduledSendLeaseExpiresAt"];
9
+ /** Parses a stored date that may come back as a `Date` or (Mongo) an ISO string; `undefined` if it isn't one. Copy of
10
+ * restapi's `toValidDate()` (`routes/BaseMessageRoute.ts`). */
11
+ function toValidDate(value) {
12
+ const date = value instanceof Date ? value : typeof value === "string" || typeof value === "number" ? new Date(value) : undefined;
13
+ return date && !Number.isNaN(date.getTime()) ? date : undefined;
14
+ }
15
+ /** Whether a send of `message` is in flight: its `scheduledSendLeaseExpiresAt` (set when `send()` or `ScheduledSendJob`
16
+ * claims it for relay) is still in the future. restapi refuses to move or delete such a message (409,
17
+ * `assertNotInFlight()`): a moved or soft-deleted message could miss its relay marker and be sent a second time. */
18
+ export function hasLiveSendLease(message) {
19
+ const lease = toValidDate(message.scheduledSendLeaseExpiresAt);
20
+ return lease !== undefined && lease.getTime() > Date.now();
21
+ }
22
+ /**
23
+ * Decides whether an ActiveSync client may move `message` from a folder of `sourceType` into one of `destinationType`,
24
+ * matching restapi's own REST rules for a non-trusted caller (`BaseMessageRoute.prepareScheduledSendUpdate()`), plus
25
+ * one of this plugin's own:
26
+ * - **Into Outbox** is always refused (`"destination"`): only a send puts a message there, after checking its sender -
27
+ * `ScheduledSendJob` relays whatever it finds in Outbox.
28
+ * - **Into Drafts** is refused unless the message already is in Drafts, or is taken back out of Outbox to cancel its send
29
+ * (`"destination"`): a draft's body can be rewritten over ActiveSync (`EmailSyncAdapter.fromApplicationData`), so moving a
30
+ * received, sent or held message into Drafts, editing it and moving it back would forge its content under its original
31
+ * sender and dates. Out of Outbox only for what a send queued: a delivered message (`scanResultUid`) a mail filter rule
32
+ * filed into Outbox is refused, as restapi refuses it with 403.
33
+ * - **A send in flight** (`hasLiveSendLease()` - claimed for relay by `send()` or `ScheduledSendJob`) can't be moved
34
+ * anywhere (`"inFlight"`), as restapi's `assertNotInFlight()` refuses with 409: moving it back to Drafts would let it be
35
+ * sent again while the first relay still runs. Once the lease has lapsed, it can move. Deletes check the same.
36
+ * - **Out of Outbox** cancels the scheduled send: the plan clears `scheduledSendTime`, the job's retry state and its
37
+ * lease (`scheduledSendAttempts`/`scheduledSendError`/`scheduledSendLeaseExpiresAt`, only where the stored row has them -
38
+ * the SQL model of restapi 0.9.0 has no such columns). A message the job already relayed (`scheduledSendRelayedAt`, whose
39
+ * filing is still pending) is refused (`"sent"`).
40
+ */
41
+ export function planMessageMove(message, sourceType, destinationType) {
42
+ if (hasLiveSendLease(message)) {
43
+ return { allowed: false, reason: "inFlight" };
44
+ }
45
+ if (destinationType === FolderType.OUTBOX) {
46
+ return { allowed: false, reason: "destination" };
47
+ }
48
+ // A send only queues drafts: moving one back to Drafts cancels the send. A delivered message in Outbox isn't one.
49
+ const cancellingSend = sourceType === FolderType.OUTBOX && !message.scanResultUid;
50
+ if (destinationType === FolderType.DRAFTS && sourceType !== FolderType.DRAFTS && !cancellingSend) {
51
+ return { allowed: false, reason: "destination" };
52
+ }
53
+ const patch = {};
54
+ if (sourceType === FolderType.OUTBOX) {
55
+ if (message.scheduledSendRelayedAt) {
56
+ return { allowed: false, reason: "sent" };
57
+ }
58
+ patch.scheduledSendTime = null;
59
+ for (const field of SCHEDULED_SEND_RETRY_FIELDS) {
60
+ if (field in message) {
61
+ patch[field] = null;
62
+ }
63
+ }
64
+ }
65
+ return { allowed: true, patch };
66
+ }
67
+ /**
68
+ * `Message` fields only a delivery or a send ever sets - server-managed in restapi (a client's create/update can't set
69
+ * them), and never present on a draft composed over ActiveSync (`EmailSyncAdapter`), REST or webmail compose:
70
+ * - `scanResultUid`: inbound delivery (and import) scanned the message into the mailbox;
71
+ * - `scheduledSendRelayedAt`: the transport accepted it (set until it is filed into Sent Items);
72
+ * - `sanitizedHtmlBlobKey`: a scan produced sanitized HTML - a delivery, or `scanAndRelay()` on a send, which keeps it on
73
+ * the Sent Items copy (a draft is never scanned);
74
+ * - `encrypted: true`: computed by the same scan;
75
+ * - `recallRequestedAt`: `recall()` only works on a sent message;
76
+ * - a non-empty `receiptStatus`: seeded when a sent message is filed.
77
+ */
78
+ const SENT_OR_DELIVERED_MARKERS = [
79
+ (message) => !!message.scanResultUid,
80
+ (message) => message.scheduledSendRelayedAt != null,
81
+ (message) => !!message.sanitizedHtmlBlobKey,
82
+ (message) => message.encrypted === true,
83
+ (message) => message.recallRequestedAt != null,
84
+ (message) => Array.isArray(message.receiptStatus) && message.receiptStatus.length > 0,
85
+ ];
86
+ /**
87
+ * Whether `message`, filed in a folder of `folderType`, is a genuine draft whose content an ActiveSync client may
88
+ * rewrite: it lives in Drafts and carries none of the marks a delivery or a send leaves (`SENT_OR_DELIVERED_MARKERS`).
89
+ * Moves into Drafts are refused over ActiveSync (`planMessageMove()`), but a message can still reach Drafts other ways
90
+ * (restapi's REST moves out of Outbox, a mail filter, an import), so a delivered or sent message found there is not
91
+ * treated as a draft.
92
+ *
93
+ * `sentDate` can't be the marker: every message has one, and a draft gets it when it is created. **Residual**: a
94
+ * plain-text, unencrypted sent copy with no receipt request carries none of these marks; restapi keeps it out of
95
+ * Drafts (non-trusted moves into Drafts only from Drafts or Outbox, and only a send from Drafts queues into Outbox).
96
+ */
97
+ export function isGenuineDraft(message, folderType) {
98
+ return folderType === FolderType.DRAFTS && !SENT_OR_DELIVERED_MARKERS.some((isMarked) => isMarked(message));
99
+ }
100
+ //# sourceMappingURL=MessageMoveRules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MessageMoveRules.js","sourceRoot":"","sources":["../../src/MessageMoveRules.ts"],"names":[],"mappings":"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"}