@remit/backend 0.0.1

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 (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,72 @@
1
+ import {
2
+ AccountConfigRepo,
3
+ AccountExportRequestRepo,
4
+ AccountRepo,
5
+ AccountSettingRepo,
6
+ AddressRepo,
7
+ DrizzleEnvelopeRepository,
8
+ DrizzleMessageFlagRepository,
9
+ DrizzleMessageRepository,
10
+ DrizzleThreadMessageRepository,
11
+ DrizzleUnitOfWork,
12
+ FilterAnchorRepo,
13
+ FilterRepo,
14
+ LabelRepo,
15
+ MailboxLockRepo,
16
+ MailboxRepo,
17
+ MailboxSpecialUseRepo,
18
+ MessageFlagPushRepo,
19
+ MessageLabelRepo,
20
+ MessagePlacementMoveRepo,
21
+ messageDataSchema,
22
+ OrganizeJobRequestRepo,
23
+ OutboxMessageRepo,
24
+ } from "@remit/drizzle-service";
25
+ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
26
+ import { env } from "expect-env";
27
+ import {
28
+ buildSharedDeps,
29
+ createRemitClient,
30
+ type RemitClient,
31
+ type RemitClientRepositories,
32
+ } from "./create-remit-client.js";
33
+
34
+ export const buildPostgresClient = (): RemitClient => {
35
+ const pgConnectionUrl = env.PG_CONNECTION_URL;
36
+
37
+ // One drizzle db instance shared across repos.
38
+ // The schema is registered for message-data tables; i4 repos use the same
39
+ // underlying connection and only need the builder API (no relational queries).
40
+ const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
41
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
42
+
43
+ const messageDataDb = db as unknown as NodePgDatabase<
44
+ typeof messageDataSchema
45
+ >;
46
+
47
+ const repositories: RemitClientRepositories = {
48
+ accountConfig: new AccountConfigRepo(genericDb),
49
+ account: new AccountRepo(genericDb),
50
+ accountSetting: new AccountSettingRepo(genericDb),
51
+ address: new AddressRepo(genericDb),
52
+ mailbox: new MailboxRepo(genericDb),
53
+ mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
54
+ mailboxLock: new MailboxLockRepo(genericDb),
55
+ message: new DrizzleMessageRepository(messageDataDb),
56
+ messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
57
+ outboxMessage: new OutboxMessageRepo(genericDb),
58
+ threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
59
+ envelope: new DrizzleEnvelopeRepository(messageDataDb),
60
+ accountExportRequest: new AccountExportRequestRepo(genericDb),
61
+ organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
62
+ placementMove: new MessagePlacementMoveRepo(genericDb),
63
+ flagPush: new MessageFlagPushRepo(genericDb),
64
+ filter: new FilterRepo(genericDb),
65
+ filterAnchor: new FilterAnchorRepo(genericDb),
66
+ label: new LabelRepo(genericDb),
67
+ messageLabel: new MessageLabelRepo(genericDb),
68
+ unitOfWork: new DrizzleUnitOfWork(messageDataDb),
69
+ };
70
+
71
+ return createRemitClient({ repositories, ...buildSharedDeps() });
72
+ };
@@ -0,0 +1,80 @@
1
+ import {
2
+ AccountConfigRepo,
3
+ AccountExportRequestRepo,
4
+ AccountRepo,
5
+ AccountSettingRepo,
6
+ AddressRepo,
7
+ createSqliteDatabase,
8
+ DrizzleEnvelopeRepository,
9
+ DrizzleMessageFlagRepository,
10
+ DrizzleMessageRepository,
11
+ DrizzleThreadMessageRepository,
12
+ DrizzleUnitOfWork,
13
+ FilterAnchorRepo,
14
+ FilterRepo,
15
+ LabelRepo,
16
+ MailboxLockRepo,
17
+ MailboxRepo,
18
+ MailboxSpecialUseRepo,
19
+ MessageFlagPushRepo,
20
+ MessageLabelRepo,
21
+ MessagePlacementMoveRepo,
22
+ messageDataSchema,
23
+ OrganizeJobRequestRepo,
24
+ OutboxMessageRepo,
25
+ } from "@remit/drizzle-service";
26
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
27
+ import { env } from "expect-env";
28
+ import {
29
+ buildSharedDeps,
30
+ createRemitClient,
31
+ type RemitClient,
32
+ type RemitClientRepositories,
33
+ } from "./create-remit-client.js";
34
+
35
+ // The SQLite backend (RFC 036). Mirrors the Postgres composition — the same
36
+ // dialect-neutral Drizzle repos, wired to one shared SQLite file instead of a
37
+ // Postgres server — with two differences the file topology forces (D3): every
38
+ // repo runs on the single serialized connection `createSqliteDatabase` opens
39
+ // (writes cannot bypass serialization, so a plain repo write never joins an
40
+ // open transaction's savepoint), and `threadMessage` takes that shared handle
41
+ // rather than its own connection string, so its writes enlist in the same
42
+ // unit-of-work transaction and the same write queue as everything else.
43
+ export const buildSqliteClient = async (): Promise<RemitClient> => {
44
+ const sqliteDbPath = env.SQLITE_DB_PATH;
45
+
46
+ const { db } = await createSqliteDatabase(messageDataSchema, {
47
+ filename: sqliteDbPath,
48
+ });
49
+ const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
50
+
51
+ const messageDataDb = db as unknown as NodePgDatabase<
52
+ typeof messageDataSchema
53
+ >;
54
+
55
+ const repositories: RemitClientRepositories = {
56
+ accountConfig: new AccountConfigRepo(genericDb),
57
+ account: new AccountRepo(genericDb),
58
+ accountSetting: new AccountSettingRepo(genericDb),
59
+ address: new AddressRepo(genericDb),
60
+ mailbox: new MailboxRepo(genericDb),
61
+ mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
62
+ mailboxLock: new MailboxLockRepo(genericDb),
63
+ message: new DrizzleMessageRepository(messageDataDb),
64
+ messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
65
+ outboxMessage: new OutboxMessageRepo(genericDb),
66
+ threadMessage: new DrizzleThreadMessageRepository(genericDb),
67
+ envelope: new DrizzleEnvelopeRepository(messageDataDb),
68
+ accountExportRequest: new AccountExportRequestRepo(genericDb),
69
+ organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
70
+ placementMove: new MessagePlacementMoveRepo(genericDb),
71
+ flagPush: new MessageFlagPushRepo(genericDb),
72
+ filter: new FilterRepo(genericDb),
73
+ filterAnchor: new FilterAnchorRepo(genericDb),
74
+ label: new LabelRepo(genericDb),
75
+ messageLabel: new MessageLabelRepo(genericDb),
76
+ unitOfWork: new DrizzleUnitOfWork(messageDataDb),
77
+ };
78
+
79
+ return createRemitClient({ repositories, ...buildSharedDeps() });
80
+ };
@@ -0,0 +1,358 @@
1
+ import type {
2
+ IAccountConfigRepository,
3
+ IAccountExportRequestRepository,
4
+ IAccountRepository,
5
+ IAccountSettingRepository,
6
+ IAddressRepository,
7
+ IEnvelopeRepository,
8
+ IFilterAnchorRepository,
9
+ IFilterRepository,
10
+ ILabelRepository,
11
+ IMailboxLockRepository,
12
+ IMailboxRepository,
13
+ IMailboxSpecialUseRepository,
14
+ IMessageFlagPushRepository,
15
+ IMessageFlagRepository,
16
+ IMessageLabelRepository,
17
+ IMessagePlacementMoveRepository,
18
+ IMessageRepository,
19
+ IOrganizeJobRequestRepository,
20
+ IOutboxMessageRepository,
21
+ IThreadMessageRepository,
22
+ IUnitOfWork,
23
+ } from "@remit/data-ports";
24
+ import { logger } from "@remit/logger-lambda";
25
+ import {
26
+ BodySyncQueueService,
27
+ BodySyncService,
28
+ createConnection,
29
+ FlagPushService,
30
+ FlagQueueService,
31
+ type IImapConnection,
32
+ MailboxQueueService,
33
+ MessageMoveService,
34
+ OutboxQueueService,
35
+ } from "@remit/mailbox-service";
36
+ import { createSearchService, type SearchService } from "@remit/search-service";
37
+ import {
38
+ buildEmbeddingServiceFromEnv,
39
+ buildVectorStoreFromEnv,
40
+ } from "@remit/search-service/from-env";
41
+ import {
42
+ createCachedDataKeyProvider,
43
+ createKmsDataKeyProvider,
44
+ createSecretsService,
45
+ deserializeEncryptedPayload,
46
+ FAKE_KMS_KEY_ID,
47
+ type SecretsService,
48
+ } from "@remit/secrets-service";
49
+ import type { StorageService } from "@remit/storage-service";
50
+ import { createStorageService } from "@remit/storage-service/s3";
51
+ import { env } from "expect-env";
52
+
53
+ export interface ConnectionScope {
54
+ getConnection: () => Promise<IImapConnection>;
55
+ disconnect: () => Promise<void>;
56
+ }
57
+
58
+ export interface RemitClient {
59
+ // Data repositories (satisfied by ElectroDB services or Drizzle repos)
60
+ accountConfig: IAccountConfigRepository;
61
+ account: IAccountRepository;
62
+ accountSetting: IAccountSettingRepository;
63
+ address: IAddressRepository;
64
+ mailbox: IMailboxRepository;
65
+ mailboxSpecialUse: IMailboxSpecialUseRepository;
66
+ mailboxLock: IMailboxLockRepository;
67
+ message: IMessageRepository;
68
+ messageFlag: IMessageFlagRepository;
69
+ outboxMessage: IOutboxMessageRepository;
70
+ threadMessage: IThreadMessageRepository;
71
+ envelope: IEnvelopeRepository;
72
+ accountExportRequest: IAccountExportRequestRepository;
73
+
74
+ // Smart Organize back-apply job (RFC 034, #1278). Present on both backends,
75
+ // modeled on accountExportRequest — a Pending row the account-fanout worker
76
+ // picks up and drives to Complete/Failed. Never a Filter.
77
+ organizeJobRequest: IOrganizeJobRequestRepository;
78
+
79
+ // Smart Organize (RFC 034, epic #1280). Present on both backends
80
+ // (`FilterService`/`LabelService`/… on DynamoDB, `FilterRepo`/`LabelRepo`/…
81
+ // on Postgres) so the matching pipeline and filter CRUD run unchanged on
82
+ // either. The Postgres side has no TTL reaper for expired Temporary filters;
83
+ // match-time correctness gates on `expiresAt` (RFC 034 Decision 1.1), never
84
+ // on the row still existing, so the missing reaper is housekeeping only.
85
+ filter: IFilterRepository;
86
+ filterAnchor: IFilterAnchorRepository;
87
+ label: ILabelRepository;
88
+ messageLabel: IMessageLabelRepository;
89
+
90
+ // Atomic write set for a message save. Present on Postgres (real
91
+ // transaction); absent on DynamoDB, where callers fall back to per-repo
92
+ // writes with that backend's own (non-transactional) guarantees.
93
+ unitOfWork?: IUnitOfWork;
94
+
95
+ // Storage service
96
+ storage: StorageService;
97
+
98
+ // Search service (semantic vector search)
99
+ search: SearchService;
100
+
101
+ // Secrets service (KMS encryption)
102
+ secrets: SecretsService;
103
+
104
+ // Body sync service (on-demand IMAP body fetch)
105
+ bodySync: BodySyncService;
106
+
107
+ // Re-arms the SYNC_MESSAGE_BODY cue when a read-path body fetch finds the
108
+ // storage object missing. Absent when SQS_QUEUE_URL_BODY is not configured
109
+ // (the deployed API Lambda does not carry the body queue today).
110
+ bodySyncQueue?: BodySyncQueueService;
111
+
112
+ // Pending placement-move markers (issue #1271). Present on both backends
113
+ // (`MessagePlacementMoveService` on DynamoDB, `MessagePlacementMoveRepo` on
114
+ // Postgres) so a caller never needs to guess which backend is active. Used
115
+ // for read-time count prediction (epic #1281 invariant 4) and by the
116
+ // account-worker cascade delete. Written by the imap-worker's bulk
117
+ // body-sync path through this `placementMove`, so the placement producer
118
+ // runs on whatever backend is active — DynamoDB, Postgres, or SQLite.
119
+ placementMove: IMessagePlacementMoveRepository;
120
+
121
+ // Pending flag-push markers (issue #1273). Present on both backends
122
+ // (`MessageFlagPushService` on DynamoDB, `MessageFlagPushRepo` on
123
+ // Postgres). Used for read-time unseenCount prediction (epic #1281
124
+ // invariant 4), by the account-worker cascade delete, and by the periodic
125
+ // per-mailbox sync tick to re-arm a marker stuck `pending`. Unlike
126
+ // `placementMove`, this one IS written on both backends — `flagQueue`
127
+ // below writes through it regardless of `DATA_BACKEND`.
128
+ flagPush: IMessageFlagPushRepository;
129
+
130
+ // Queue services (writes with IMAP sync)
131
+ flagQueue: FlagQueueService;
132
+ mailboxQueue: MailboxQueueService;
133
+ messageMove: MessageMoveService;
134
+ outboxQueue: OutboxQueueService;
135
+
136
+ // Helper to create IMAP connection scope from accountId
137
+ createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
138
+ }
139
+
140
+ export interface RemitClientRepositories {
141
+ accountConfig: IAccountConfigRepository;
142
+ account: IAccountRepository;
143
+ accountSetting: IAccountSettingRepository;
144
+ address: IAddressRepository;
145
+ mailbox: IMailboxRepository;
146
+ mailboxSpecialUse: IMailboxSpecialUseRepository;
147
+ mailboxLock: IMailboxLockRepository;
148
+ message: IMessageRepository;
149
+ messageFlag: IMessageFlagRepository;
150
+ outboxMessage: IOutboxMessageRepository;
151
+ threadMessage: IThreadMessageRepository;
152
+ envelope: IEnvelopeRepository;
153
+ accountExportRequest: IAccountExportRequestRepository;
154
+ organizeJobRequest: IOrganizeJobRequestRepository;
155
+ placementMove: IMessagePlacementMoveRepository;
156
+ flagPush: IMessageFlagPushRepository;
157
+ filter: IFilterRepository;
158
+ filterAnchor: IFilterAnchorRepository;
159
+ label: ILabelRepository;
160
+ messageLabel: IMessageLabelRepository;
161
+ unitOfWork?: IUnitOfWork;
162
+ }
163
+
164
+ export interface RemitClientSharedDeps {
165
+ storage: StorageService;
166
+ search: SearchService;
167
+ secrets: SecretsService;
168
+ sqsQueueUrl: string;
169
+ sqsSmtpQueueUrl: string;
170
+ bodySyncQueue?: BodySyncQueueService;
171
+ }
172
+
173
+ export interface RemitClientDeps extends RemitClientSharedDeps {
174
+ repositories: RemitClientRepositories;
175
+ }
176
+
177
+ const buildConnectionScope =
178
+ (accountService: IAccountRepository, secretsService: SecretsService) =>
179
+ async (accountId: string): Promise<ConnectionScope> => {
180
+ const account = await accountService.get(accountId);
181
+ if (!account.passwordHash) {
182
+ throw new Error(
183
+ `Account ${accountId} has no passwordHash — OAuth accounts cannot use this connection path`,
184
+ );
185
+ }
186
+ const password = await secretsService.decrypt(
187
+ deserializeEncryptedPayload(JSON.parse(account.passwordHash)),
188
+ );
189
+
190
+ let connection: IImapConnection | null = null;
191
+ let connectPromise: Promise<IImapConnection> | null = null;
192
+
193
+ const getConnection = async (): Promise<IImapConnection> => {
194
+ if (connectPromise) {
195
+ return connectPromise;
196
+ }
197
+
198
+ const conn = createConnection({
199
+ user: account.username,
200
+ credentials: { kind: "password", password },
201
+ host: account.imapHost,
202
+ port: account.imapPort,
203
+ tls: account.imapTls,
204
+ });
205
+ connection = conn;
206
+ connectPromise = conn.connect().then(() => conn);
207
+
208
+ return connectPromise;
209
+ };
210
+
211
+ const disconnect = async (): Promise<void> => {
212
+ if (connection) {
213
+ await connection.disconnect();
214
+ connection = null;
215
+ connectPromise = null;
216
+ }
217
+ };
218
+
219
+ return { getConnection, disconnect };
220
+ };
221
+
222
+ // The vector store and embedder are selected from the environment by the shared
223
+ // builders: S3 Vectors + Bedrock Titan in production, persistent sqlite-vec +
224
+ // Transformers.js when the LOCAL_* / local flags are set (local dev), and the
225
+ // in-memory store + deterministic embedder otherwise (tests).
226
+ const buildSearchService = (): SearchService => {
227
+ // Build the embedder first so we can pass its dimension count to the
228
+ // sqlite-vec store — the vec0 table dimension must match the embedder.
229
+ const embedder = buildEmbeddingServiceFromEnv();
230
+ return createSearchService({
231
+ store: buildVectorStoreFromEnv(embedder.dimensions),
232
+ embedder,
233
+ });
234
+ };
235
+
236
+ // The body queue lives on the worker infra; the API only knows it locally
237
+ // (dev / e2e set SQS_QUEUE_URL_BODY). When unset the read path still returns a
238
+ // retryable 202 — it just cannot re-arm the cue itself.
239
+ const buildBodySyncQueue = (): BodySyncQueueService | undefined => {
240
+ const bodyQueueUrl = process.env.SQS_QUEUE_URL_BODY;
241
+ if (!bodyQueueUrl) return undefined;
242
+ return new BodySyncQueueService({ sqsQueueUrl: bodyQueueUrl, logger });
243
+ };
244
+
245
+ // Everything a RemitClient needs that is chosen from the environment and does
246
+ // not vary by data backend: storage, search, secrets, the SQS queue URLs, and
247
+ // the optional body-sync queue. Each composition root builds its backend's
248
+ // repositories and merges them with this.
249
+ export const buildSharedDeps = (): RemitClientSharedDeps => {
250
+ const storage = createStorageService();
251
+ const search = buildSearchService();
252
+ const kmsKeyId = process.env.KMS_KEY_ID ?? FAKE_KMS_KEY_ID;
253
+ const dataKeyProvider = createCachedDataKeyProvider(
254
+ createKmsDataKeyProvider(kmsKeyId),
255
+ );
256
+ const secrets = createSecretsService(dataKeyProvider);
257
+ const sqsQueueUrl = env.SQS_QUEUE_URL;
258
+ const sqsSmtpQueueUrl = process.env.SQS_QUEUE_URL_SMTP ?? sqsQueueUrl;
259
+ return {
260
+ storage,
261
+ search,
262
+ secrets,
263
+ sqsQueueUrl,
264
+ sqsSmtpQueueUrl,
265
+ bodySyncQueue: buildBodySyncQueue(),
266
+ };
267
+ };
268
+
269
+ // Backend-neutral composition root: given repositories (from any data-ports
270
+ // implementation) and the shared services, wire the domain and queue services
271
+ // and assemble a RemitClient. Imports neither ElectroDB nor Drizzle — the
272
+ // caller chooses the backend.
273
+ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
274
+ const {
275
+ repositories,
276
+ storage,
277
+ search,
278
+ secrets,
279
+ sqsQueueUrl,
280
+ sqsSmtpQueueUrl,
281
+ bodySyncQueue,
282
+ } = deps;
283
+
284
+ const bodySync = new BodySyncService(
285
+ repositories.message,
286
+ storage,
287
+ repositories.threadMessage,
288
+ repositories.address,
289
+ repositories.envelope,
290
+ logger,
291
+ );
292
+
293
+ const flagPushService = new FlagPushService({
294
+ markerService: repositories.flagPush,
295
+ sqsQueueUrl,
296
+ logger,
297
+ });
298
+
299
+ return {
300
+ accountConfig: repositories.accountConfig,
301
+ account: repositories.account,
302
+ accountSetting: repositories.accountSetting,
303
+ address: repositories.address,
304
+ mailbox: repositories.mailbox,
305
+ mailboxSpecialUse: repositories.mailboxSpecialUse,
306
+ mailboxLock: repositories.mailboxLock,
307
+ message: repositories.message,
308
+ messageFlag: repositories.messageFlag,
309
+ outboxMessage: repositories.outboxMessage,
310
+ threadMessage: repositories.threadMessage,
311
+ envelope: repositories.envelope,
312
+ accountExportRequest: repositories.accountExportRequest,
313
+ organizeJobRequest: repositories.organizeJobRequest,
314
+ filter: repositories.filter,
315
+ filterAnchor: repositories.filterAnchor,
316
+ label: repositories.label,
317
+ messageLabel: repositories.messageLabel,
318
+ unitOfWork: repositories.unitOfWork,
319
+
320
+ storage,
321
+ search,
322
+ secrets,
323
+
324
+ bodySync,
325
+ bodySyncQueue,
326
+ placementMove: repositories.placementMove,
327
+ flagPush: repositories.flagPush,
328
+
329
+ flagQueue: new FlagQueueService({
330
+ messageFlagService: repositories.messageFlag,
331
+ messageService: repositories.message,
332
+ threadMessageService: repositories.threadMessage,
333
+ flagPushService,
334
+ logger,
335
+ }),
336
+ mailboxQueue: new MailboxQueueService({
337
+ mailboxService: repositories.mailbox,
338
+ sqsQueueUrl,
339
+ logger,
340
+ }),
341
+ messageMove: new MessageMoveService({
342
+ messageService: repositories.message,
343
+ mailboxService: repositories.mailbox,
344
+ mailboxSpecialUseService: repositories.mailboxSpecialUse,
345
+ threadMessageService: repositories.threadMessage,
346
+ sqsQueueUrl,
347
+ logger,
348
+ }),
349
+ outboxQueue: new OutboxQueueService({
350
+ outboxMessageService: repositories.outboxMessage,
351
+ accountService: repositories.account,
352
+ sqsSmtpQueueUrl,
353
+ logger,
354
+ }),
355
+
356
+ createConnectionScope: buildConnectionScope(repositories.account, secrets),
357
+ };
358
+ };
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { afterEach, test } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { _resetForTest, getClient, type RemitClient } from "./dynamodb.js";
6
+
7
+ // The DynamoDB composition module is stripped from the open-core tree; its
8
+ // no-regression guard runs where the module ships and skips where it does not.
9
+ const hasDynamoComposition = existsSync(
10
+ fileURLToPath(new URL("./compose-dynamodb.ts", import.meta.url)),
11
+ );
12
+
13
+ const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
14
+ "accountConfig",
15
+ "account",
16
+ "accountSetting",
17
+ "address",
18
+ "mailbox",
19
+ "mailboxSpecialUse",
20
+ "message",
21
+ "messageFlag",
22
+ "outboxMessage",
23
+ "threadMessage",
24
+ "envelope",
25
+ "accountExportRequest",
26
+ "storage",
27
+ "search",
28
+ "secrets",
29
+ "bodySync",
30
+ "flagQueue",
31
+ "mailboxQueue",
32
+ "messageMove",
33
+ "outboxQueue",
34
+ "createConnectionScope",
35
+ ] as const;
36
+
37
+ afterEach(() => {
38
+ _resetForTest();
39
+ });
40
+
41
+ test("getClient() with DATA_BACKEND=postgres constructs all services without throwing", async () => {
42
+ const savedBackend = process.env.DATA_BACKEND;
43
+ const savedPgUrl = process.env.PG_CONNECTION_URL;
44
+
45
+ process.env.DATA_BACKEND = "postgres";
46
+ process.env.PG_CONNECTION_URL =
47
+ "postgresql://remit:remit@localhost:5432/remit_test";
48
+
49
+ try {
50
+ const client = await getClient();
51
+
52
+ for (const key of REQUIRED_KEYS) {
53
+ assert.ok(
54
+ client[key] !== undefined && client[key] !== null,
55
+ `RemitClient.${key} must be defined on the postgres path`,
56
+ );
57
+ }
58
+
59
+ assert.equal(typeof client.createConnectionScope, "function");
60
+ } finally {
61
+ _resetForTest();
62
+ if (savedBackend === undefined) {
63
+ delete process.env.DATA_BACKEND;
64
+ } else {
65
+ process.env.DATA_BACKEND = savedBackend;
66
+ }
67
+ if (savedPgUrl === undefined) {
68
+ delete process.env.PG_CONNECTION_URL;
69
+ } else {
70
+ process.env.PG_CONNECTION_URL = savedPgUrl;
71
+ }
72
+ }
73
+ });
74
+
75
+ test(
76
+ "getClient() without DATA_BACKEND constructs all services without throwing (DynamoDB path, no regression)",
77
+ { skip: !hasDynamoComposition },
78
+ async () => {
79
+ const savedBackend = process.env.DATA_BACKEND;
80
+ delete process.env.DATA_BACKEND;
81
+
82
+ try {
83
+ const client = await getClient();
84
+
85
+ for (const key of REQUIRED_KEYS) {
86
+ assert.ok(
87
+ client[key] !== undefined && client[key] !== null,
88
+ `RemitClient.${key} must be defined on the DynamoDB path`,
89
+ );
90
+ }
91
+
92
+ assert.equal(typeof client.createConnectionScope, "function");
93
+ } finally {
94
+ _resetForTest();
95
+ if (savedBackend !== undefined) {
96
+ process.env.DATA_BACKEND = savedBackend;
97
+ }
98
+ }
99
+ },
100
+ );
@@ -0,0 +1,58 @@
1
+ import type { RemitClient } from "./create-remit-client.js";
2
+
3
+ export type {
4
+ ConnectionScope,
5
+ RemitClient,
6
+ } from "./create-remit-client.js";
7
+
8
+ // The DynamoDB composition module is stripped from the open-core export (it
9
+ // imports the closed electrodb-service). The `as string` specifier keeps
10
+ // tsgo from resolving it there — where the file is absent — while this named
11
+ // contract keeps the call site typed rather than `any`. The relational tree
12
+ // never takes this branch; the DynamoDB deploy, where the module is present,
13
+ // resolves it at runtime.
14
+ type ComposeDynamoDBModule = { buildDynamoDBClient: () => RemitClient };
15
+
16
+ let clientPromise: Promise<RemitClient> | null = null;
17
+
18
+ // The API process and the imap-worker share this composition root. Each backend
19
+ // is loaded through a dynamic import of its own composition module, so the
20
+ // module that a given deploy never runs never enters its graph: `@remit/remit-
21
+ // drizzle-service`/`drizzle-orm` stay out of a Lambda bundle (both `external`
22
+ // for the Lambda esbuild build; `remit-lambda-bundles.test.ts` enforces this),
23
+ // and the DynamoDB composition — which imports the closed `@remit/remit-
24
+ // electrodb-service` — stays out of the relational (open-core) tree, where its
25
+ // module is not shipped at all. esbuild still bundles the DynamoDB path into
26
+ // the Lambda because `./compose-dynamodb.js` is a relative import, not an
27
+ // `external`.
28
+ export const getClient = (): Promise<RemitClient> => {
29
+ if (!clientPromise) {
30
+ if (process.env.DATA_BACKEND === "postgres") {
31
+ clientPromise = import("./compose-postgres.js").then((m) =>
32
+ m.buildPostgresClient(),
33
+ );
34
+ } else if (process.env.DATA_BACKEND === "sqlite") {
35
+ clientPromise = import("./compose-sqlite.js").then((m) =>
36
+ m.buildSqliteClient(),
37
+ );
38
+ } else {
39
+ clientPromise = (
40
+ import(
41
+ "./compose-dynamodb.js" as string
42
+ ) as Promise<ComposeDynamoDBModule>
43
+ ).then((m) => m.buildDynamoDBClient());
44
+ }
45
+ }
46
+
47
+ return clientPromise;
48
+ };
49
+
50
+ /** Reset the singleton — test use only. */
51
+ export const _resetForTest = (): void => {
52
+ clientPromise = null;
53
+ };
54
+
55
+ /** Inject a (usually partial) client — test use only. */
56
+ export const _setClientForTest = (override: RemitClient): void => {
57
+ clientPromise = Promise.resolve(override);
58
+ };