@dxos/plugin-jmap 0.10.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 (180) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/JmapPlugin.mjs +16 -0
  3. package/dist/lib/JmapPlugin.mjs.map +1 -0
  4. package/dist/lib/apis.mjs +2 -0
  5. package/dist/lib/capabilities.mjs +17 -0
  6. package/dist/lib/capabilities.mjs.map +1 -0
  7. package/dist/lib/chunk-apis.mjs +756 -0
  8. package/dist/lib/chunk-apis.mjs.map +1 -0
  9. package/dist/lib/chunk-connector.mjs +111 -0
  10. package/dist/lib/chunk-connector.mjs.map +1 -0
  11. package/dist/lib/chunk-constants.mjs +23 -0
  12. package/dist/lib/chunk-constants.mjs.map +1 -0
  13. package/dist/lib/chunk-errors.mjs +42 -0
  14. package/dist/lib/chunk-errors.mjs.map +1 -0
  15. package/dist/lib/chunk-handler.mjs +27 -0
  16. package/dist/lib/chunk-handler.mjs.map +1 -0
  17. package/dist/lib/chunk-jmap-fixtures.mjs +102 -0
  18. package/dist/lib/chunk-jmap-fixtures.mjs.map +1 -0
  19. package/dist/lib/chunk-mail-send.mjs +17 -0
  20. package/dist/lib/chunk-mail-send.mjs.map +1 -0
  21. package/dist/lib/chunk-meta.mjs +37 -0
  22. package/dist/lib/chunk-meta.mjs.map +1 -0
  23. package/dist/lib/chunk-operation-handler.mjs +12 -0
  24. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  25. package/dist/lib/chunk-send.mjs +98 -0
  26. package/dist/lib/chunk-send.mjs.map +1 -0
  27. package/dist/lib/chunk-sync-provider.mjs +511 -0
  28. package/dist/lib/chunk-sync-provider.mjs.map +1 -0
  29. package/dist/lib/chunk-sync.mjs +37 -0
  30. package/dist/lib/chunk-sync.mjs.map +1 -0
  31. package/dist/lib/index.mjs +4 -0
  32. package/dist/lib/meta.mjs +2 -0
  33. package/dist/lib/operations.mjs +13 -0
  34. package/dist/lib/operations.mjs.map +1 -0
  35. package/dist/lib/plugin.mjs +9 -0
  36. package/dist/lib/plugin.mjs.map +1 -0
  37. package/dist/lib/services.mjs +139 -0
  38. package/dist/lib/services.mjs.map +1 -0
  39. package/dist/lib/testing/node.mjs +2 -0
  40. package/dist/lib/testing.mjs +19 -0
  41. package/dist/lib/testing.mjs.map +1 -0
  42. package/dist/lib/translations.mjs +7 -0
  43. package/dist/lib/translations.mjs.map +1 -0
  44. package/dist/types/dx.config.d.ts +28 -0
  45. package/dist/types/dx.config.d.ts.map +1 -0
  46. package/dist/types/src/JmapPlugin.d.ts +9 -0
  47. package/dist/types/src/JmapPlugin.d.ts.map +1 -0
  48. package/dist/types/src/apis/Jmap/api.d.ts +42 -0
  49. package/dist/types/src/apis/Jmap/api.d.ts.map +1 -0
  50. package/dist/types/src/apis/Jmap/index.d.ts +3 -0
  51. package/dist/types/src/apis/Jmap/index.d.ts.map +1 -0
  52. package/dist/types/src/apis/Jmap/types.d.ts +65 -0
  53. package/dist/types/src/apis/Jmap/types.d.ts.map +1 -0
  54. package/dist/types/src/apis/JmapMail/api.d.ts +223 -0
  55. package/dist/types/src/apis/JmapMail/api.d.ts.map +1 -0
  56. package/dist/types/src/apis/JmapMail/index.d.ts +4 -0
  57. package/dist/types/src/apis/JmapMail/index.d.ts.map +1 -0
  58. package/dist/types/src/apis/JmapMail/query.d.ts +26 -0
  59. package/dist/types/src/apis/JmapMail/query.d.ts.map +1 -0
  60. package/dist/types/src/apis/JmapMail/query.test.d.ts +2 -0
  61. package/dist/types/src/apis/JmapMail/query.test.d.ts.map +1 -0
  62. package/dist/types/src/apis/JmapMail/types.d.ts +290 -0
  63. package/dist/types/src/apis/JmapMail/types.d.ts.map +1 -0
  64. package/dist/types/src/apis/index.d.ts +3 -0
  65. package/dist/types/src/apis/index.d.ts.map +1 -0
  66. package/dist/types/src/apis/jmap-api.test.d.ts +2 -0
  67. package/dist/types/src/apis/jmap-api.test.d.ts.map +1 -0
  68. package/dist/types/src/capabilities/connector.d.ts +5 -0
  69. package/dist/types/src/capabilities/connector.d.ts.map +1 -0
  70. package/dist/types/src/capabilities/credential-form.d.ts +37 -0
  71. package/dist/types/src/capabilities/credential-form.d.ts.map +1 -0
  72. package/dist/types/src/capabilities/credential-form.test.d.ts +2 -0
  73. package/dist/types/src/capabilities/credential-form.test.d.ts.map +1 -0
  74. package/dist/types/src/capabilities/index.d.ts +5 -0
  75. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  76. package/dist/types/src/capabilities/mail-send.d.ts +6 -0
  77. package/dist/types/src/capabilities/mail-send.d.ts.map +1 -0
  78. package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
  79. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  80. package/dist/types/src/constants.d.ts +19 -0
  81. package/dist/types/src/constants.d.ts.map +1 -0
  82. package/dist/types/src/errors.d.ts +103 -0
  83. package/dist/types/src/errors.d.ts.map +1 -0
  84. package/dist/types/src/index.d.ts +4 -0
  85. package/dist/types/src/index.d.ts.map +1 -0
  86. package/dist/types/src/meta.d.ts +33 -0
  87. package/dist/types/src/meta.d.ts.map +1 -0
  88. package/dist/types/src/operations/index.d.ts +3 -0
  89. package/dist/types/src/operations/index.d.ts.map +1 -0
  90. package/dist/types/src/operations/mail/mapper.d.ts +49 -0
  91. package/dist/types/src/operations/mail/mapper.d.ts.map +1 -0
  92. package/dist/types/src/operations/mail/materialize/handler.d.ts +11 -0
  93. package/dist/types/src/operations/mail/materialize/handler.d.ts.map +1 -0
  94. package/dist/types/src/operations/mail/send/handler.d.ts +4 -0
  95. package/dist/types/src/operations/mail/send/handler.d.ts.map +1 -0
  96. package/dist/types/src/operations/mail/send/index.d.ts +2 -0
  97. package/dist/types/src/operations/mail/send/index.d.ts.map +1 -0
  98. package/dist/types/src/operations/mail/sync/handler.d.ts +4 -0
  99. package/dist/types/src/operations/mail/sync/handler.d.ts.map +1 -0
  100. package/dist/types/src/operations/mail/sync/index.d.ts +3 -0
  101. package/dist/types/src/operations/mail/sync/index.d.ts.map +1 -0
  102. package/dist/types/src/operations/mail/sync/mapper.test.d.ts +2 -0
  103. package/dist/types/src/operations/mail/sync/mapper.test.d.ts.map +1 -0
  104. package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts +2 -0
  105. package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts.map +1 -0
  106. package/dist/types/src/operations/mail/sync/sync-provider.d.ts +11 -0
  107. package/dist/types/src/operations/mail/sync/sync-provider.d.ts.map +1 -0
  108. package/dist/types/src/operations/mail/sync/sync.test.d.ts +2 -0
  109. package/dist/types/src/operations/mail/sync/sync.test.d.ts.map +1 -0
  110. package/dist/types/src/operations/mail/sync/system-tags.d.ts +14 -0
  111. package/dist/types/src/operations/mail/sync/system-tags.d.ts.map +1 -0
  112. package/dist/types/src/operations/mail/tags.d.ts +12 -0
  113. package/dist/types/src/operations/mail/tags.d.ts.map +1 -0
  114. package/dist/types/src/plugin.d.ts +4 -0
  115. package/dist/types/src/plugin.d.ts.map +1 -0
  116. package/dist/types/src/services/index.d.ts +3 -0
  117. package/dist/types/src/services/index.d.ts.map +1 -0
  118. package/dist/types/src/services/jmap-credentials.d.ts +39 -0
  119. package/dist/types/src/services/jmap-credentials.d.ts.map +1 -0
  120. package/dist/types/src/services/jmap-mail-api.d.ts +109 -0
  121. package/dist/types/src/services/jmap-mail-api.d.ts.map +1 -0
  122. package/dist/types/src/testing/index.d.ts +5 -0
  123. package/dist/types/src/testing/index.d.ts.map +1 -0
  124. package/dist/types/src/testing/jmap-fixtures.d.ts +26 -0
  125. package/dist/types/src/testing/jmap-fixtures.d.ts.map +1 -0
  126. package/dist/types/src/testing/jmap-fixtures.test.d.ts +2 -0
  127. package/dist/types/src/testing/jmap-fixtures.test.d.ts.map +1 -0
  128. package/dist/types/src/testing/node.d.ts +4 -0
  129. package/dist/types/src/testing/node.d.ts.map +1 -0
  130. package/dist/types/src/testing/sync-fixture.d.ts +22 -0
  131. package/dist/types/src/testing/sync-fixture.d.ts.map +1 -0
  132. package/dist/types/src/translations.d.ts +8 -0
  133. package/dist/types/src/translations.d.ts.map +1 -0
  134. package/dist/types/tsconfig.tsbuildinfo +1 -0
  135. package/dx.config.ts +32 -0
  136. package/package.json +121 -0
  137. package/src/JmapPlugin.ts +25 -0
  138. package/src/apis/Jmap/api.ts +138 -0
  139. package/src/apis/Jmap/index.ts +6 -0
  140. package/src/apis/Jmap/types.ts +86 -0
  141. package/src/apis/JmapMail/api.ts +324 -0
  142. package/src/apis/JmapMail/index.ts +7 -0
  143. package/src/apis/JmapMail/query.test.ts +181 -0
  144. package/src/apis/JmapMail/query.ts +231 -0
  145. package/src/apis/JmapMail/types.ts +196 -0
  146. package/src/apis/index.ts +8 -0
  147. package/src/apis/jmap-api.test.ts +261 -0
  148. package/src/capabilities/connector.ts +44 -0
  149. package/src/capabilities/credential-form.test.ts +69 -0
  150. package/src/capabilities/credential-form.ts +113 -0
  151. package/src/capabilities/index.ts +24 -0
  152. package/src/capabilities/mail-send.ts +21 -0
  153. package/src/capabilities/operation-handler.ts +16 -0
  154. package/src/constants.ts +24 -0
  155. package/src/errors.ts +38 -0
  156. package/src/index.ts +7 -0
  157. package/src/meta.ts +9 -0
  158. package/src/operations/index.ts +13 -0
  159. package/src/operations/mail/mapper.ts +191 -0
  160. package/src/operations/mail/materialize/handler.ts +41 -0
  161. package/src/operations/mail/send/handler.ts +116 -0
  162. package/src/operations/mail/send/index.ts +5 -0
  163. package/src/operations/mail/sync/handler.ts +52 -0
  164. package/src/operations/mail/sync/index.ts +7 -0
  165. package/src/operations/mail/sync/mapper.test.ts +150 -0
  166. package/src/operations/mail/sync/sync-e2e.test.ts +63 -0
  167. package/src/operations/mail/sync/sync-provider.ts +481 -0
  168. package/src/operations/mail/sync/sync.test.ts +766 -0
  169. package/src/operations/mail/sync/system-tags.ts +24 -0
  170. package/src/operations/mail/tags.ts +29 -0
  171. package/src/plugin.ts +11 -0
  172. package/src/services/index.ts +6 -0
  173. package/src/services/jmap-credentials.ts +61 -0
  174. package/src/services/jmap-mail-api.ts +294 -0
  175. package/src/testing/index.ts +8 -0
  176. package/src/testing/jmap-fixtures.test.ts +107 -0
  177. package/src/testing/jmap-fixtures.ts +105 -0
  178. package/src/testing/node.ts +12 -0
  179. package/src/testing/sync-fixture.ts +39 -0
  180. package/src/translations.ts +15 -0
@@ -0,0 +1,766 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { subDays } from 'date-fns';
6
+ import * as Cause from 'effect/Cause';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Exit from 'effect/Exit';
9
+ import * as Layer from 'effect/Layer';
10
+ import { afterAll, beforeAll, describe, test } from 'vitest';
11
+
12
+ import { RunAgainError } from '@dxos/compute';
13
+ import * as Operation from '@dxos/compute/Operation';
14
+ import * as Trace from '@dxos/compute/Trace';
15
+ import { Blob, Database, Feed, Filter, Obj, Order, Query, Ref, Scope, Tag } from '@dxos/echo';
16
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
17
+ import { EffectEx } from '@dxos/effect';
18
+ import { Cursor } from '@dxos/link';
19
+ import * as InboxOperation from '@dxos/plugin-inbox/InboxOperation';
20
+ import * as Mailbox from '@dxos/plugin-inbox/Mailbox';
21
+ import { createSyncProgressKey } from '@dxos/plugin-inbox/sync';
22
+ import * as SystemTags from '@dxos/plugin-inbox/SystemTags';
23
+ import { ambientSyncServices, seedMailboxBinding, seedSenderOrganizations } from '@dxos/plugin-inbox/testing/sync';
24
+ import { TagIndex } from '@dxos/schema';
25
+ import { Message, Person } from '@dxos/types';
26
+
27
+ import { JMAP_DOMAIN, JMAP_MAIL_CONNECTOR_ID } from '../../../constants';
28
+ import { type JmapDataset, JmapMailApi } from '../../../services';
29
+ import { generateJmapDataset } from '../../../testing/jmap-fixtures';
30
+ import { jmapSyncTestServices, runJmapSync } from '../../../testing/sync-fixture';
31
+ import { JMAP_KEYWORD_TAGS, JMAP_ROLE_TAGS } from './system-tags';
32
+
33
+ /** Reads all synced messages from a seeded mailbox's feed. */
34
+ const queryFeedMessages = (db: Database.Database, mailbox: Mailbox.Mailbox) =>
35
+ db.query(Query.select(Filter.type(Message.Message)).from(Scope.feed(Feed.getFeedUri(mailbox.feed.target!)!))).run();
36
+
37
+ /** Feed insertion order (oldest-inserted first) as message `created` timestamps. */
38
+ const insertionOrderTimestamps = async (db: Database.Database, mailbox: Mailbox.Mailbox) => {
39
+ const feed = mailbox.feed.target!;
40
+ const messages = await db
41
+ .query(
42
+ Query.select(Filter.type(Message.Message))
43
+ .from(Scope.feed(Feed.getFeedUri(feed)!))
44
+ .orderBy(Order.natural('asc')),
45
+ )
46
+ .run();
47
+ return messages.map((message) => Date.parse(message.created));
48
+ };
49
+
50
+ const maxKey = (dataset: JmapDataset) =>
51
+ Math.max(...dataset.emails.map((email) => new Date(email.receivedAt).getTime()));
52
+ const minKey = (dataset: JmapDataset) =>
53
+ Math.min(...dataset.emails.map((email) => new Date(email.receivedAt).getTime()));
54
+
55
+ /**
56
+ * Wraps a mock {@link JmapMailApi} so `emailGet` dies after `n` calls — simulates a mid-run crash to
57
+ * assert the committed prefix is durable and a following run resumes.
58
+ */
59
+ const withFaultAfterEmails = (n: number, dataset: JmapDataset): Layer.Layer<JmapMailApi> =>
60
+ Layer.effect(
61
+ JmapMailApi,
62
+ Effect.gen(function* () {
63
+ const inner = yield* JmapMailApi;
64
+ let count = 0;
65
+ return JmapMailApi.of({
66
+ ...inner,
67
+ emailGet: (target, ids, properties) => {
68
+ // The empty-ids call is the first-tick state-token capture, not a message fetch — don't count
69
+ // it toward the injected fetch fault.
70
+ if (ids.length === 0) {
71
+ return inner.emailGet(target, ids, properties);
72
+ }
73
+ count += 1;
74
+ return count > n ? Effect.die(new Error('injected fault')) : inner.emailGet(target, ids, properties);
75
+ },
76
+ });
77
+ }),
78
+ ).pipe(Layer.provide(JmapMailApi.mock(dataset)));
79
+
80
+ /** Wraps a mock {@link JmapMailApi} recording every `emailGet` id, so a test can assert which emails
81
+ * were downloaded (vs skipped as already-synced before fetch). */
82
+ const countingJmapApi = (dataset: JmapDataset): { layer: Layer.Layer<JmapMailApi>; fetched: string[] } => {
83
+ const fetched: string[] = [];
84
+ const layer = Layer.effect(
85
+ JmapMailApi,
86
+ Effect.gen(function* () {
87
+ const inner = yield* JmapMailApi;
88
+ return JmapMailApi.of({
89
+ ...inner,
90
+ emailGet: (target, ids, properties) => {
91
+ fetched.push(...ids);
92
+ return inner.emailGet(target, ids, properties);
93
+ },
94
+ });
95
+ }),
96
+ ).pipe(Layer.provide(JmapMailApi.mock(dataset)));
97
+ return { layer, fetched };
98
+ };
99
+
100
+ // The JMAP sync driven end-to-end against a real ECHO db + a mock JMAP API — no live account. The whole
101
+ // pipeline (query, dedup, decode, map, extraction, commit, cursor advance) exercises against generated data.
102
+ describe('runJmapSync against a mock JMAP API', () => {
103
+ let builder: EchoTestBuilder;
104
+
105
+ beforeAll(async () => {
106
+ builder = await new EchoTestBuilder().open();
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await builder.close();
111
+ });
112
+
113
+ const seed = (options?: { max?: string; min?: string; options?: Record<string, unknown> }) =>
114
+ seedMailboxBinding(builder, { source: JMAP_DOMAIN, connectorId: JMAP_MAIL_CONNECTOR_ID, ...options });
115
+ const jmapKeyIds = (message: Message.Message) =>
116
+ Obj.getMeta(message)
117
+ .keys.filter((key) => key.source === JMAP_DOMAIN)
118
+ .map((key) => key.id);
119
+ const syncedIdsOf = async (db: Database.Database, mailbox: Mailbox.Mailbox) =>
120
+ (await queryFeedMessages(db, mailbox)).flatMap(jmapKeyIds);
121
+
122
+ test('syncs generated emails into the feed with contacts, threads, tags, and cursor', async ({ expect }) => {
123
+ // A window inside the 30-day horizon and away from its day boundaries, so the walk covers the whole
124
+ // dataset regardless of local timezone.
125
+ const end = subDays(new Date(), 3);
126
+ const start = subDays(new Date(), 12);
127
+ const dataset = generateJmapDataset({ count: 40, seed: 11, start, end });
128
+
129
+ const { db, mailbox, binding } = await seed();
130
+
131
+ // Contact extraction is an allow-list, and these fixtures generate random senders — see
132
+ // `seedSenderOrganizations`.
133
+ await seedSenderOrganizations(db, dataset);
134
+
135
+ const { result, feedMessages } = await EffectEx.runPromise(
136
+ Effect.gen(function* () {
137
+ const result = yield* runJmapSync({ binding: Ref.make(binding) });
138
+ const feedMessages = yield* Effect.promise(() => queryFeedMessages(db, mailbox));
139
+ return { result, feedMessages };
140
+ }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
141
+ );
142
+
143
+ // The window walk fetched some emails; every synced message is a distinct dataset email.
144
+ const datasetIds = new Set(dataset.emails.map((email) => email.id));
145
+ const syncedIds = await syncedIdsOf(db, mailbox);
146
+ expect(syncedIds.length).toBeGreaterThan(0);
147
+ expect(new Set(syncedIds).size).toBe(syncedIds.length);
148
+ expect(syncedIds.every((id) => datasetIds.has(id))).toBe(true);
149
+
150
+ // `newMessages` is consistent with what landed in the feed.
151
+ expect(result.newMessages).toBe(feedMessages.length);
152
+
153
+ // Contacts: one Person per distinct sender among the messages that actually synced.
154
+ const senderEmails = new Set(feedMessages.map((message) => message.sender?.email).filter(Boolean));
155
+ const people = await db.query(Filter.type(Person.Person)).run();
156
+ expect(people.length).toBe(senderEmails.size);
157
+
158
+ // Folder tags: well-known roles (inbox/sent) map onto shared canonical tags — dropped roles
159
+ // (archive/drafts/trash/junk) produce none — and custom folders become provider-scoped tags.
160
+ // Keyword canonical tags (starred) are materialized up front alongside the folder tags.
161
+ const tags = await db.query(Filter.type(Tag.Tag)).run();
162
+ const canonical = new Set([
163
+ ...dataset.folders.flatMap((folder) => {
164
+ const id = folder.role ? JMAP_ROLE_TAGS[folder.role] : undefined;
165
+ return id ? [id] : [];
166
+ }),
167
+ ...Object.values(JMAP_KEYWORD_TAGS).flatMap((id) => (id ? [id] : [])),
168
+ ]);
169
+ const customCount = dataset.folders.filter((folder) => !folder.role).length;
170
+ expect(tags.length).toBe(canonical.size + customCount);
171
+
172
+ // Cursor advanced to the last synced key; backfill completed within the run (small dataset).
173
+ expect(binding.max).toBeDefined();
174
+ expect(Number.parseInt(binding.max!, 10)).toBeGreaterThan(0);
175
+ expect(binding.min).toBeDefined();
176
+
177
+ // Re-running is a no-op: dedup + cursor prevent duplicate work.
178
+ const rerun = await EffectEx.runPromise(
179
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
180
+ );
181
+ expect(rerun.newMessages).toBe(0);
182
+ const afterRerun = await queryFeedMessages(db, mailbox);
183
+ expect(afterRerun.length).toBe(feedMessages.length);
184
+ expect((await db.query(Filter.type(Person.Person)).run()).length).toBe(people.length);
185
+ });
186
+
187
+ test('a re-run does not re-download already-synced messages (id-level dedup before fetch)', async ({ expect }) => {
188
+ // Pinned clock so the re-run's forward window lands on the same high-water boundary as the first sync.
189
+ const now = new Date('2026-07-16T12:00:00.000Z');
190
+ const dataset = generateJmapDataset({ count: 12, seed: 55, start: subDays(now, 6), end: subDays(now, 1) });
191
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
192
+
193
+ // Initial full sync.
194
+ await EffectEx.runPromise(
195
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
196
+ );
197
+ expect((await syncedIdsOf(db, mailbox)).length).toBe(dataset.emails.length);
198
+
199
+ // Re-run with a counting API. The forward window still *queries* the boundary message's id
200
+ // (inclusive `after: max`), but it is already committed, so `skipCommitted` drops it before
201
+ // `emailGet` — nothing downloads.
202
+ const { layer, fetched } = countingJmapApi(dataset);
203
+ const rerun = await EffectEx.runPromise(
204
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
205
+ Effect.provide(Layer.mergeAll(layer, ambientSyncServices(db))),
206
+ ),
207
+ );
208
+ expect(rerun.newMessages).toBe(0);
209
+ expect(fetched).toEqual([]);
210
+ });
211
+
212
+ test('emits trace status updates with advancing progress during sync', async ({ expect }) => {
213
+ const end = subDays(new Date(), 3);
214
+ const start = subDays(new Date(), 12);
215
+ const dataset = generateJmapDataset({ count: 20, seed: 17, start, end });
216
+
217
+ const { db, mailbox, binding } = await seed();
218
+
219
+ const statusUpdates: Trace.PayloadType<typeof Trace.StatusUpdate>[] = [];
220
+ const traceLayer = Trace.testTraceService().pipe(
221
+ Layer.provide(
222
+ Layer.succeed(Trace.TraceSink, {
223
+ write: (message) => {
224
+ for (const event of Trace.flatten(message)) {
225
+ if (Trace.isOfType(Trace.StatusUpdate, event)) {
226
+ statusUpdates.push(event.data);
227
+ }
228
+ }
229
+ },
230
+ }),
231
+ ),
232
+ );
233
+
234
+ await EffectEx.runPromise(
235
+ runJmapSync({ binding: Ref.make(binding) }).pipe(
236
+ Effect.provide(jmapSyncTestServices(db, dataset, { traceLayer })),
237
+ ),
238
+ );
239
+
240
+ const progressCurrents = statusUpdates
241
+ .map((update) => update.progress?.current)
242
+ .filter((current): current is number => current !== undefined);
243
+ expect(progressCurrents.length).toBeGreaterThan(0);
244
+ expect(Math.max(...progressCurrents)).toBeGreaterThan(0);
245
+ expect(statusUpdates.some((update) => update.progress?.total !== undefined && update.progress.total > 0)).toBe(
246
+ true,
247
+ );
248
+ expect(statusUpdates.every((update) => update.progress?.key === createSyncProgressKey(mailbox))).toBe(true);
249
+ expect(statusUpdates.some((update) => update.message === mailbox.name)).toBe(true);
250
+ });
251
+
252
+ test('initial backward, incremental forward, and widening syncBackDays reopens backfill', async ({ expect }) => {
253
+ const now = new Date();
254
+ // Three disjoint date bands (distinct ids via idPrefix), oldest → newest: older, mid, recent.
255
+ const mid = generateJmapDataset({
256
+ count: 10,
257
+ seed: 1,
258
+ start: subDays(now, 13),
259
+ end: subDays(now, 8),
260
+ idPrefix: 'mid',
261
+ });
262
+ const recent = generateJmapDataset({
263
+ count: 8,
264
+ seed: 2,
265
+ start: subDays(now, 5),
266
+ end: subDays(now, 2),
267
+ idPrefix: 'new',
268
+ });
269
+ const older = generateJmapDataset({
270
+ count: 6,
271
+ seed: 3,
272
+ start: subDays(now, 25),
273
+ end: subDays(now, 20),
274
+ idPrefix: 'old',
275
+ });
276
+ const union = (...datasets: (typeof mid)[]) => ({
277
+ session: mid.session,
278
+ folders: mid.folders,
279
+ emails: datasets.flatMap((dataset) => dataset.emails),
280
+ });
281
+
282
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
283
+
284
+ // 1) Initial: no cursor → backward from today down to the 14-day horizon. Only `mid` is in range.
285
+ const r1 = await EffectEx.runPromise(
286
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, mid))),
287
+ );
288
+ expect(r1.newMessages).toBe(mid.emails.length);
289
+ expect(Number.parseInt(binding.max!, 10)).toBe(maxKey(mid)); // max set to the newest synced.
290
+ const lowAfterInitial = Number.parseInt(binding.min!, 10);
291
+ expect(lowAfterInitial).toBeLessThan(minKey(mid)); // backfill completed: min clamped to the horizon.
292
+
293
+ // 2) Incremental: forward from `max`. A newer band has arrived; only it syncs, `min` unchanged.
294
+ const r2 = await EffectEx.runPromise(
295
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, union(mid, recent)))),
296
+ );
297
+ expect(r2.newMessages).toBe(recent.emails.length);
298
+ expect(Number.parseInt(binding.max!, 10)).toBe(maxKey(recent));
299
+ expect(Number.parseInt(binding.min!, 10)).toBe(lowAfterInitial);
300
+
301
+ // 3) Widen syncBackDays to 30 — the horizon moves below `min`, reopening backward. `older` is in
302
+ // range; `max` stays put (older keys don't exceed it).
303
+ Obj.update(binding, (binding) => {
304
+ if (binding.spec.kind === 'external') {
305
+ binding.spec.options = { ...(binding.spec.options ?? {}), syncBackDays: 30 };
306
+ }
307
+ });
308
+ const highBeforeWiden = binding.max;
309
+ const r3 = await EffectEx.runPromise(
310
+ runJmapSync({ binding: Ref.make(binding) }).pipe(
311
+ Effect.provide(jmapSyncTestServices(db, union(mid, recent, older))),
312
+ ),
313
+ );
314
+ expect(r3.newMessages).toBe(older.emails.length);
315
+ expect(binding.max).toBe(highBeforeWiden);
316
+ expect(Number.parseInt(binding.min!, 10)).toBeLessThan(minKey(older));
317
+
318
+ // All three bands landed exactly once.
319
+ const ids = await syncedIdsOf(db, mailbox);
320
+ expect(new Set(ids).size).toBe(ids.length);
321
+ expect(ids.length).toBe(mid.emails.length + recent.emails.length + older.emails.length);
322
+ });
323
+
324
+ test('an interrupted initial sync commits durably and a following run resumes both halves', async ({ expect }) => {
325
+ const now = new Date();
326
+ const dataset = generateJmapDataset({ count: 20, seed: 23, start: subDays(now, 10), end: subDays(now, 2) });
327
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
328
+
329
+ // Fault after the first commit page (COMMIT_PAGE_SIZE = 10) — simulates a crash partway through
330
+ // the initial backward (newest-first) walk.
331
+ const exit = await EffectEx.runPromise(
332
+ Effect.exit(runJmapSync({ binding: Ref.make(binding) })).pipe(
333
+ Effect.provide(ambientSyncServices(db)),
334
+ Effect.provide(withFaultAfterEmails(10, dataset)),
335
+ ),
336
+ );
337
+ expect(Exit.isFailure(exit)).toBe(true);
338
+
339
+ // The committed page is durable and a contiguous newest suffix (backward walks newest-first); `min`
340
+ // is the oldest committed key, `max` the newest — no completion (the run errored, not exhausted).
341
+ const committedAfterFault = await syncedIdsOf(db, mailbox);
342
+ expect(committedAfterFault).toHaveLength(10);
343
+ const sortedDesc = [...dataset.emails].sort(
344
+ (left, right) => new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime(),
345
+ );
346
+ expect(new Set(committedAfterFault)).toEqual(new Set(sortedDesc.slice(0, 10).map((email) => email.id)));
347
+ expect(Number.parseInt(binding.max!, 10)).toBe(maxKey(dataset));
348
+ expect(Number.parseInt(binding.min!, 10)).toBe(new Date(sortedDesc[9].receivedAt).getTime());
349
+
350
+ // Recovery: a healthy run resumes the backward half from `min`, nothing new forward — everything
351
+ // lands exactly once.
352
+ await EffectEx.runPromise(
353
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
354
+ );
355
+ const finalIds = await syncedIdsOf(db, mailbox);
356
+ expect(new Set(finalIds).size).toBe(finalIds.length);
357
+ expect(finalIds).toHaveLength(dataset.emails.length);
358
+ expect(Number.parseInt(binding.min!, 10)).toBeLessThan(minKey(dataset));
359
+ });
360
+
361
+ test('a capped run requests Operation.runAgain(), and repeated runs sync the whole mailbox', async ({ expect }) => {
362
+ const now = new Date();
363
+ const dataset = generateJmapDataset({ count: 30, seed: 29, start: subDays(now, 5), end: subDays(now, 1) });
364
+ const { db, mailbox, binding } = await seed();
365
+
366
+ const runOnce = () =>
367
+ EffectEx.runPromise(
368
+ Effect.exit(runJmapSync({ binding: Ref.make(binding), maxMessages: 10 })).pipe(
369
+ Effect.provide(jmapSyncTestServices(db, dataset)),
370
+ ),
371
+ );
372
+
373
+ // What must hold every run: never a duplicate, never fewer messages than before, a `RunAgainError`
374
+ // exit exactly while capped, and eventual completion with the whole mailbox landed exactly once.
375
+ let previousCount = 0;
376
+ let runs = 0;
377
+ let exit: Exit.Exit<unknown, unknown>;
378
+ do {
379
+ exit = await runOnce();
380
+ runs += 1;
381
+ const ids = await syncedIdsOf(db, mailbox);
382
+ expect(new Set(ids).size).toBe(ids.length);
383
+ expect(ids.length).toBeGreaterThanOrEqual(previousCount);
384
+ previousCount = ids.length;
385
+ if (Exit.isFailure(exit)) {
386
+ expect(RunAgainError.is(Cause.squash(exit.cause))).toBe(true);
387
+ }
388
+ } while (Exit.isFailure(exit) && runs < 10);
389
+
390
+ expect(Exit.isSuccess(exit)).toBe(true);
391
+ const finalIds = await syncedIdsOf(db, mailbox);
392
+ expect(new Set(finalIds).size).toBe(finalIds.length);
393
+ expect(finalIds).toHaveLength(dataset.emails.length);
394
+ });
395
+
396
+ test(
397
+ 'the newest message is not re-committed across backfill runs once it ages out of the dedup seed — ' +
398
+ 'regression: the forward high-boundary re-fetch duplicated it every run',
399
+ async ({ expect }) => {
400
+ const now = new Date();
401
+ const dataset = generateJmapDataset({ count: 30, seed: 31, start: subDays(now, 5), end: subDays(now, 1) });
402
+ const { db, mailbox, binding } = await seed();
403
+
404
+ // `dedupSeedTail` (5) < messages backfilled after the newest, so a newest-only seed would evict the
405
+ // newest from the dedup set and the forward `after: max` re-fetch would re-commit it (prod: >500).
406
+ const runOnce = () =>
407
+ EffectEx.runPromise(
408
+ Effect.exit(runJmapSync({ binding: Ref.make(binding), maxMessages: 10, dedupSeedTail: 5 })).pipe(
409
+ Effect.provide(jmapSyncTestServices(db, dataset)),
410
+ ),
411
+ );
412
+
413
+ let runs = 0;
414
+ let exit: Exit.Exit<unknown, unknown>;
415
+ do {
416
+ exit = await runOnce();
417
+ runs += 1;
418
+ const ids = await syncedIdsOf(db, mailbox);
419
+ // The invariant the bug violated: never a duplicate, on any run.
420
+ expect(new Set(ids).size).toBe(ids.length);
421
+ } while (Exit.isFailure(exit) && runs < 10);
422
+
423
+ expect(Exit.isSuccess(exit)).toBe(true);
424
+ const finalIds = await syncedIdsOf(db, mailbox);
425
+ expect(new Set(finalIds).size).toBe(finalIds.length);
426
+ expect(finalIds).toHaveLength(dataset.emails.length);
427
+ },
428
+ );
429
+
430
+ test("backward keeps JMAP's newest-first order and forward reverses to oldest-first", async ({ expect }) => {
431
+ // Small enough to land in one page (QUERY_PAGE_SIZE = 50), so feed insertion order directly
432
+ // reflects the query order.
433
+ const end = subDays(new Date(), 3);
434
+ const dataset = generateJmapDataset({ count: 4, seed: 7, start: subDays(end, 2), end });
435
+
436
+ // No cursor → backward (initial sync): newest-first.
437
+ const backward = await seed();
438
+ await EffectEx.runPromise(
439
+ runJmapSync({ binding: Ref.make(backward.binding) }).pipe(
440
+ Effect.provide(jmapSyncTestServices(backward.db, dataset)),
441
+ ),
442
+ );
443
+ const backwardOrder = await insertionOrderTimestamps(backward.db, backward.mailbox);
444
+ expect(backwardOrder).toEqual([...backwardOrder].sort((left, right) => right - left));
445
+
446
+ // Forward (incremental resume): seed a cursor just below the dataset with `syncBackDays` short enough
447
+ // that the horizon sits above `min`, so only the forward window is active. Oldest-first, so a capped
448
+ // run advances `max` gap-free instead of jumping to the newest key and stranding the middle.
449
+ const forwardCursor = String(minKey(dataset) - 1);
450
+ const forward = await seed({ max: forwardCursor, min: forwardCursor, options: { syncBackDays: 1 } });
451
+ await EffectEx.runPromise(
452
+ runJmapSync({ binding: Ref.make(forward.binding) }).pipe(
453
+ Effect.provide(jmapSyncTestServices(forward.db, dataset)),
454
+ ),
455
+ );
456
+ const forwardOrder = await insertionOrderTimestamps(forward.db, forward.mailbox);
457
+ expect(forwardOrder).toEqual([...forwardOrder].sort((left, right) => left - right));
458
+ });
459
+
460
+ test('attachments land as Blobs on the feed with resolvable attachment refs', async ({ expect }) => {
461
+ const end = subDays(new Date(), 3);
462
+ const base = generateJmapDataset({ count: 1, seed: 5, start: subDays(end, 1), end });
463
+ const bytes = new Uint8Array([1, 2, 3, 4]);
464
+ const dataset = {
465
+ session: { ...base.session, downloadUrl: 'https://jmap.test/download/{accountId}/{blobId}/{name}?type={type}' },
466
+ folders: base.folders,
467
+ emails: [{ ...base.emails[0], attachments: [{ blobId: 'blob-1', name: 'photo.png', type: 'image/png' }] }],
468
+ blobs: { 'blob-1': bytes },
469
+ };
470
+
471
+ const { db, mailbox, binding } = await seed();
472
+ await EffectEx.runPromise(
473
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
474
+ );
475
+
476
+ const feedMessages = await queryFeedMessages(db, mailbox);
477
+ expect(feedMessages).toHaveLength(1);
478
+ const [attachment] = feedMessages[0].attachments ?? [];
479
+ if (!attachment) {
480
+ throw new Error('expected an attachment');
481
+ }
482
+ expect(attachment.name).toBe('photo.png');
483
+
484
+ const blobs = await db
485
+ .query(Query.select(Filter.type(Blob.Blob)).from(Scope.feed(Feed.getFeedUri(mailbox.feed.target!)!)))
486
+ .run();
487
+ expect(blobs).toHaveLength(1);
488
+
489
+ const loadedBlob = await attachment.ref.load();
490
+ expect(loadedBlob.id).toEqual(blobs[0].id);
491
+ });
492
+
493
+ test('JmapSync is marked idempotent for durable-execution retry', ({ expect }) => {
494
+ expect(Operation.isIdempotent(InboxOperation.JmapSync)).toBe(true);
495
+ });
496
+
497
+ //
498
+ // Incremental (Email/changes) sync.
499
+ //
500
+
501
+ const tokenOf = (binding: Cursor.Cursor): string | undefined =>
502
+ binding.spec.kind === 'external' ? binding.spec.token : undefined;
503
+
504
+ test('the first tick captures the Email/get state token (before backfill)', async ({ expect }) => {
505
+ const end = subDays(new Date(), 3);
506
+ const start = subDays(new Date(), 12);
507
+ const dataset = { ...generateJmapDataset({ count: 6, seed: 81, start, end }), state: 'state-0' };
508
+ const { db, mailbox, binding } = await seed();
509
+
510
+ await EffectEx.runPromise(
511
+ runJmapSync({ binding: Ref.make(binding) }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
512
+ );
513
+
514
+ // The state token is captured and the (window) backfill still ran normally.
515
+ expect(tokenOf(binding)).toBe('state-0');
516
+ expect((await syncedIdsOf(db, mailbox)).length).toBe(dataset.emails.length);
517
+ });
518
+
519
+ test('an incremental run syncs only the delta created messages and advances the token', async ({ expect }) => {
520
+ const now = new Date('2026-07-16T12:00:00.000Z');
521
+ const base = generateJmapDataset({ count: 5, seed: 82, start: subDays(now, 6), end: subDays(now, 2) });
522
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
523
+
524
+ // First tick: window backfill + capture 'state-0'.
525
+ await EffectEx.runPromise(
526
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
527
+ Effect.provide(jmapSyncTestServices(db, { ...base, state: 'state-0' })),
528
+ ),
529
+ );
530
+ expect(tokenOf(binding)).toBe('state-0');
531
+ expect((await syncedIdsOf(db, mailbox)).length).toBe(base.emails.length);
532
+
533
+ // A new message arrives; the change log advances state-0 → state-1 with it created.
534
+ const arrival = generateJmapDataset({ count: 1, seed: 83, start: subDays(now, 1), end: now, idPrefix: 'new' })
535
+ .emails[0];
536
+ const run2 = {
537
+ ...base,
538
+ emails: [...base.emails, arrival],
539
+ state: 'state-1',
540
+ changeLog: [{ sinceState: 'state-0', newState: 'state-1', created: [arrival.id] }],
541
+ };
542
+ const r2 = await EffectEx.runPromise(
543
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, run2))),
544
+ );
545
+
546
+ // Only the delta's created message synced; the token advanced to the new state.
547
+ expect(r2.newMessages).toBe(1);
548
+ expect(tokenOf(binding)).toBe('state-1');
549
+ const ids = await syncedIdsOf(db, mailbox);
550
+ expect(ids).toContain(arrival.id);
551
+ expect(ids.length).toBe(base.emails.length + 1);
552
+ });
553
+
554
+ test('a stale state token clears it, falls back to the window scan, and recaptures', async ({ expect }) => {
555
+ const now = new Date('2026-07-16T12:00:00.000Z');
556
+ const base = generateJmapDataset({ count: 5, seed: 84, start: subDays(now, 6), end: subDays(now, 2) });
557
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
558
+
559
+ // A prior run left a token the server can no longer resolve (evicted past its retention window).
560
+ Obj.update(binding, (binding) => {
561
+ if (binding.spec.kind === 'external') {
562
+ binding.spec.token = 'evicted';
563
+ }
564
+ });
565
+
566
+ // No change-log chain from 'evicted' → the mock fails with `cannotCalculateChanges`.
567
+ const dataset = { ...base, state: 'state-current', changeLog: [] };
568
+ const result = await EffectEx.runPromise(
569
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, dataset))),
570
+ );
571
+
572
+ // Fell back to the full window scan and recaptured the fresh token.
573
+ expect(result.newMessages).toBe(base.emails.length);
574
+ expect(tokenOf(binding)).toBe('state-current');
575
+ expect((await syncedIdsOf(db, mailbox)).length).toBe(base.emails.length);
576
+ });
577
+
578
+ test('a crash mid-incremental leaves the token unadvanced and recovers with no duplicate', async ({ expect }) => {
579
+ const now = new Date('2026-07-16T12:00:00.000Z');
580
+ const base = generateJmapDataset({ count: 5, seed: 85, start: subDays(now, 6), end: subDays(now, 2) });
581
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
582
+
583
+ // First tick: backfill + capture 'state-0'.
584
+ await EffectEx.runPromise(
585
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
586
+ Effect.provide(jmapSyncTestServices(db, { ...base, state: 'state-0' })),
587
+ ),
588
+ );
589
+ expect(tokenOf(binding)).toBe('state-0');
590
+
591
+ // A large delta arrives; fault after the first commit page (10) lands.
592
+ const arrivals = generateJmapDataset({
593
+ count: 15,
594
+ seed: 86,
595
+ start: subDays(now, 1),
596
+ end: now,
597
+ idPrefix: 'new',
598
+ }).emails;
599
+ const run2Dataset = {
600
+ ...base,
601
+ emails: [...base.emails, ...arrivals],
602
+ state: 'state-1',
603
+ changeLog: [{ sinceState: 'state-0', newState: 'state-1', created: arrivals.map((email) => email.id) }],
604
+ };
605
+ const exit = await EffectEx.runPromise(
606
+ Effect.exit(runJmapSync({ binding: Ref.make(binding), now })).pipe(
607
+ Effect.provide(ambientSyncServices(db)),
608
+ Effect.provide(withFaultAfterEmails(10, run2Dataset)),
609
+ ),
610
+ );
611
+ expect(Exit.isFailure(exit)).toBe(true);
612
+ // Token NOT advanced — a crash before the delta fully drained keeps it at 'state-0'.
613
+ expect(tokenOf(binding)).toBe('state-0');
614
+
615
+ // Recovery: the next run re-fetches the whole delta, dedups the committed prefix, finishes clean.
616
+ await EffectEx.runPromise(
617
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, run2Dataset))),
618
+ );
619
+ expect(tokenOf(binding)).toBe('state-1');
620
+ const ids = await syncedIdsOf(db, mailbox);
621
+ expect(new Set(ids).size).toBe(ids.length);
622
+ expect(ids.length).toBe(base.emails.length + arrivals.length);
623
+ });
624
+
625
+ test('an incremental mailbox move retags an existing message (remote-wins) with no new feed append', async ({
626
+ expect,
627
+ }) => {
628
+ const now = new Date('2026-07-16T12:00:00.000Z');
629
+ const base = generateJmapDataset({ count: 3, seed: 90, start: subDays(now, 6), end: subDays(now, 2) });
630
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
631
+ await EffectEx.runPromise(
632
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
633
+ Effect.provide(jmapSyncTestServices(db, { ...base, state: 'state-0' })),
634
+ ),
635
+ );
636
+ const before = (await syncedIdsOf(db, mailbox)).length;
637
+ const targetId = base.emails[0].id;
638
+
639
+ // The target email was moved from Inbox to the 'Work' folder (mb-custom-1) on the server.
640
+ const movedEmails = base.emails.map((email) =>
641
+ email.id === targetId ? { ...email, mailboxIds: { 'mb-custom-1': true } } : email,
642
+ );
643
+ const run2 = {
644
+ ...base,
645
+ emails: movedEmails,
646
+ state: 'state-1',
647
+ changeLog: [{ sinceState: 'state-0', newState: 'state-1', updated: [targetId] }],
648
+ };
649
+ await EffectEx.runPromise(
650
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, run2))),
651
+ );
652
+
653
+ // No new feed message and the token advanced.
654
+ expect((await syncedIdsOf(db, mailbox)).length).toBe(before);
655
+ expect(tokenOf(binding)).toBe('state-1');
656
+
657
+ // Remote-wins: the message now carries the Work tag and no longer the Inbox tag.
658
+ const tagIndex = await EffectEx.runPromise(Database.load(mailbox.tags).pipe(Effect.provide(Database.layer(db))));
659
+ const feedMessages = await queryFeedMessages(db, mailbox);
660
+ const target = feedMessages.find((message) => jmapKeyIds(message).includes(targetId))!;
661
+ const tags = await db.query(Filter.type(Tag.Tag)).run();
662
+ const uriFor = (folderId: string) =>
663
+ Obj.getURI(tags.find((tag) => Obj.getMeta(tag).keys.some((key) => key.id === folderId))!).toString();
664
+ // The inbox role maps onto the canonical tag, not a folder-keyed provider tag.
665
+ const inboxUri = Obj.getURI(
666
+ tags.find((tag) =>
667
+ Obj.getMeta(tag).keys.some((key) => key.source === SystemTags.SYSTEM_TAG_SOURCE && key.id === 'inbox'),
668
+ )!,
669
+ ).toString();
670
+ const localTags = TagIndex.bind(tagIndex).tags(target.id);
671
+ expect(localTags).toContain(uriFor('mb-custom-1'));
672
+ expect(localTags).not.toContain(inboxUri);
673
+ });
674
+
675
+ test('an incremental $flagged keyword add stars an existing message (canonical starred tag)', async ({ expect }) => {
676
+ const now = new Date('2026-07-16T12:00:00.000Z');
677
+ const base = generateJmapDataset({ count: 3, seed: 93, start: subDays(now, 6), end: subDays(now, 2) });
678
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
679
+ await EffectEx.runPromise(
680
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
681
+ Effect.provide(jmapSyncTestServices(db, { ...base, state: 'state-0' })),
682
+ ),
683
+ );
684
+ const targetId = base.emails[0].id;
685
+
686
+ // The target email was flagged (starred) on the server.
687
+ const flaggedEmails = base.emails.map((email) =>
688
+ email.id === targetId ? { ...email, keywords: { $flagged: true } } : email,
689
+ );
690
+ const run2 = {
691
+ ...base,
692
+ emails: flaggedEmails,
693
+ state: 'state-1',
694
+ changeLog: [{ sinceState: 'state-0', newState: 'state-1', updated: [targetId] }],
695
+ };
696
+ await EffectEx.runPromise(
697
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(Effect.provide(jmapSyncTestServices(db, run2))),
698
+ );
699
+
700
+ // The message gained the canonical starred tag — the same one the local star toggle resolves.
701
+ const tagIndex = await EffectEx.runPromise(Database.load(mailbox.tags).pipe(Effect.provide(Database.layer(db))));
702
+ const feedMessages = await queryFeedMessages(db, mailbox);
703
+ const target = feedMessages.find((message) => jmapKeyIds(message).includes(targetId))!;
704
+ const tags = await db.query(Filter.type(Tag.Tag)).run();
705
+ const starredUri = Obj.getURI(
706
+ tags.find((tag) =>
707
+ Obj.getMeta(tag).keys.some((key) => key.source === SystemTags.SYSTEM_TAG_SOURCE && key.id === 'starred'),
708
+ )!,
709
+ ).toString();
710
+ expect(TagIndex.bind(tagIndex).tags(target.id)).toContain(starredUri);
711
+ });
712
+
713
+ test('a created delta larger than the per-run budget syncs across bounded runs (maxChanges)', async ({ expect }) => {
714
+ const now = new Date('2026-07-16T12:00:00.000Z');
715
+ const base = generateJmapDataset({ count: 3, seed: 94, start: subDays(now, 6), end: subDays(now, 2) });
716
+ const { db, mailbox, binding } = await seed({ options: { syncBackDays: 14 } });
717
+ await EffectEx.runPromise(
718
+ runJmapSync({ binding: Ref.make(binding), now }).pipe(
719
+ Effect.provide(jmapSyncTestServices(db, { ...base, state: 'state-0' })),
720
+ ),
721
+ );
722
+ expect(tokenOf(binding)).toBe('state-0');
723
+
724
+ // Four new emails arrive across two change steps; with a budget of 3, `Email/changes` returns them in
725
+ // bounded chunks (`hasMoreChanges` → runAgain), advancing the state token to each chunk's `newState`.
726
+ const arrivals = generateJmapDataset({
727
+ count: 4,
728
+ seed: 95,
729
+ start: subDays(now, 1),
730
+ end: now,
731
+ idPrefix: 'new',
732
+ }).emails;
733
+ const dataset = {
734
+ ...base,
735
+ emails: [...base.emails, ...arrivals],
736
+ state: 'state-2',
737
+ changeLog: [
738
+ { sinceState: 'state-0', newState: 'state-1', created: [arrivals[0].id, arrivals[1].id] },
739
+ { sinceState: 'state-1', newState: 'state-2', created: [arrivals[2].id, arrivals[3].id] },
740
+ ],
741
+ };
742
+
743
+ let runs = 0;
744
+ let exit: Exit.Exit<unknown, unknown>;
745
+ do {
746
+ exit = await EffectEx.runPromise(
747
+ Effect.exit(runJmapSync({ binding: Ref.make(binding), maxMessages: 3, now })).pipe(
748
+ Effect.provide(jmapSyncTestServices(db, dataset)),
749
+ ),
750
+ );
751
+ runs += 1;
752
+ if (Exit.isFailure(exit)) {
753
+ expect(RunAgainError.is(Cause.squash(exit.cause))).toBe(true);
754
+ }
755
+ } while (Exit.isFailure(exit) && runs < 10);
756
+
757
+ expect(Exit.isSuccess(exit)).toBe(true);
758
+ expect(runs).toBeGreaterThan(1);
759
+ expect(tokenOf(binding)).toBe('state-2');
760
+ const ids = await syncedIdsOf(db, mailbox);
761
+ for (const arrival of arrivals) {
762
+ expect(ids).toContain(arrival.id);
763
+ }
764
+ expect(ids.length).toBe(base.emails.length + 4);
765
+ });
766
+ });