@remit/backend 0.0.50 → 0.0.52
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.
- package/dev-server/content-auth.test.ts +9 -9
- package/dev-server/content-auth.ts +2 -2
- package/dev-server/relational-health.test.ts +0 -8
- package/dev-server/relational-health.ts +3 -21
- package/dev-server/server.ts +7 -9
- package/package.json +4 -7
- package/scripts/backfill-list-id.ts +1 -1
- package/src/data-backend.test.ts +1 -3
- package/src/data-backend.ts +8 -14
- package/src/derive/contentSignature.test.ts +1 -19
- package/src/derive/contentSignature.ts +10 -10
- package/src/derive/contentUrl.ts +1 -1
- package/src/handlers/account-oauth.ts +1 -1
- package/src/handlers/account.ts +1 -1
- package/src/handlers/address.ts +1 -1
- package/src/handlers/config.ts +1 -1
- package/src/handlers/filter.ts +1 -1
- package/src/handlers/folder-role.ts +1 -1
- package/src/handlers/label.ts +1 -1
- package/src/handlers/mailbox.ts +3 -3
- package/src/handlers/me.test.ts +1 -1
- package/src/handlers/me.ts +1 -1
- package/src/handlers/message.ts +1 -1
- package/src/handlers/organize.test.ts +20 -0
- package/src/handlers/organize.ts +1 -1
- package/src/handlers/outbox.ts +1 -1
- package/src/handlers/search.ts +1 -1
- package/src/handlers/sync.ts +1 -1
- package/src/handlers/thread.ts +1 -1
- package/src/handlers/unified-threads.ts +1 -1
- package/src/index.ts +2 -2
- package/src/jwt-auth.test.ts +6 -6
- package/src/jwt-auth.ts +2 -2
- package/src/service/compose-sqlite.ts +8 -14
- package/src/service/create-remit-client.ts +6 -6
- package/src/service/data-client.test.ts +96 -0
- package/src/service/data-client.ts +58 -0
- package/src/service/organize.test.ts +9 -4
- package/src/service/organize.ts +5 -7
- package/src/service/semantic-capability.test.ts +10 -16
- package/src/service/semantic-capability.ts +1 -1
- package/src/service/compose-postgres.ts +0 -76
- package/src/service/dynamodb.test.ts +0 -98
- package/src/service/dynamodb.ts +0 -57
package/src/jwt-auth.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, test } from "node:test";
|
|
|
3
3
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
4
4
|
import {
|
|
5
5
|
_setVerifierForTest,
|
|
6
|
-
|
|
6
|
+
authenticateSelfHostRequest,
|
|
7
7
|
} from "./jwt-auth.js";
|
|
8
8
|
|
|
9
9
|
const buildEvent = (
|
|
@@ -34,7 +34,7 @@ test("valid token injects verified sub into authorizer claims", async () => {
|
|
|
34
34
|
headers: { Authorization: "Bearer good.token.here" },
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
const result = await
|
|
37
|
+
const result = await authenticateSelfHostRequest(event);
|
|
38
38
|
|
|
39
39
|
assert.equal(result, null);
|
|
40
40
|
assert.equal(event.requestContext.authorizer?.claims?.sub, "user-abc");
|
|
@@ -49,7 +49,7 @@ test("invalid token returns 401 and does not inject claims", async () => {
|
|
|
49
49
|
headers: { authorization: "Bearer bad.token" },
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
const result = await
|
|
52
|
+
const result = await authenticateSelfHostRequest(event);
|
|
53
53
|
|
|
54
54
|
assert.equal(result?.statusCode, 401);
|
|
55
55
|
assert.equal(event.requestContext.authorizer, undefined);
|
|
@@ -59,7 +59,7 @@ test("no token with a local bypass configured is allowed", async () => {
|
|
|
59
59
|
process.env.LOCAL_ACCOUNT_CONFIG_ID = "some-config-id";
|
|
60
60
|
const event = buildEvent();
|
|
61
61
|
|
|
62
|
-
const result = await
|
|
62
|
+
const result = await authenticateSelfHostRequest(event);
|
|
63
63
|
|
|
64
64
|
assert.equal(result, null);
|
|
65
65
|
});
|
|
@@ -67,7 +67,7 @@ test("no token with a local bypass configured is allowed", async () => {
|
|
|
67
67
|
test("no token and no bypass returns 401", async () => {
|
|
68
68
|
const event = buildEvent();
|
|
69
69
|
|
|
70
|
-
const result = await
|
|
70
|
+
const result = await authenticateSelfHostRequest(event);
|
|
71
71
|
|
|
72
72
|
assert.equal(result?.statusCode, 401);
|
|
73
73
|
});
|
|
@@ -82,7 +82,7 @@ test("pre-injected claims (edge tier) short-circuit verification", async () => {
|
|
|
82
82
|
} as unknown as APIGatewayProxyEvent["requestContext"],
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
const result = await
|
|
85
|
+
const result = await authenticateSelfHostRequest(event);
|
|
86
86
|
|
|
87
87
|
assert.equal(result, null);
|
|
88
88
|
assert.equal(event.requestContext.authorizer?.claims?.sub, "edge-user");
|
package/src/jwt-auth.ts
CHANGED
|
@@ -56,7 +56,7 @@ const hasLocalBypass = (): boolean =>
|
|
|
56
56
|
Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Authenticate a
|
|
59
|
+
* Authenticate a self-host request from a better-auth RS256 JWT.
|
|
60
60
|
*
|
|
61
61
|
* On a valid token the verified `sub` is injected into the event's authorizer
|
|
62
62
|
* claims, exactly where the Cognito authorizer puts it, so every downstream
|
|
@@ -68,7 +68,7 @@ const hasLocalBypass = (): boolean =>
|
|
|
68
68
|
* When the edge tier (APISIX) has already verified and injected claims, this is
|
|
69
69
|
* a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
|
|
70
70
|
*/
|
|
71
|
-
export const
|
|
71
|
+
export const authenticateSelfHostRequest = async (
|
|
72
72
|
event: APIGatewayProxyEvent,
|
|
73
73
|
): Promise<APIGatewayProxyResult | null> => {
|
|
74
74
|
const existingSub = event.requestContext?.authorizer?.claims?.sub;
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
OutboxMessageRepo,
|
|
26
26
|
QuarantineRepo,
|
|
27
27
|
} from "@remit/drizzle-service";
|
|
28
|
-
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
29
28
|
import { env } from "expect-env";
|
|
30
29
|
import {
|
|
31
30
|
buildSharedDeps,
|
|
@@ -34,25 +33,20 @@ import {
|
|
|
34
33
|
type RemitClientRepositories,
|
|
35
34
|
} from "./create-remit-client.js";
|
|
36
35
|
|
|
37
|
-
// The SQLite
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// rather than its own connection string, so its writes enlist in the same
|
|
44
|
-
// unit-of-work transaction and the same write queue as everything else.
|
|
36
|
+
// The SQLite adapter composition (RFC 036). Every repo runs on the single
|
|
37
|
+
// serialized connection `createSqliteDatabase` opens (D3), so a plain repo
|
|
38
|
+
// write cannot bypass serialization and join an open transaction's savepoint,
|
|
39
|
+
// and `threadMessage` takes that same shared handle, so its writes enlist in
|
|
40
|
+
// the same unit-of-work transaction and the same write queue as everything
|
|
41
|
+
// else.
|
|
45
42
|
export const buildSqliteClient = async (): Promise<RemitClient> => {
|
|
46
43
|
const sqliteDbPath = env.SQLITE_DB_PATH;
|
|
47
44
|
|
|
48
45
|
const { db } = await createSqliteDatabase(messageDataSchema, {
|
|
49
46
|
filename: sqliteDbPath,
|
|
50
47
|
});
|
|
51
|
-
const genericDb = db
|
|
52
|
-
|
|
53
|
-
const messageDataDb = db as unknown as NodePgDatabase<
|
|
54
|
-
typeof messageDataSchema
|
|
55
|
-
>;
|
|
48
|
+
const genericDb = db;
|
|
49
|
+
const messageDataDb = db;
|
|
56
50
|
|
|
57
51
|
const repositories: RemitClientRepositories = {
|
|
58
52
|
accountConfig: new AccountConfigRepo(genericDb),
|
|
@@ -85,8 +85,8 @@ export interface RemitClient {
|
|
|
85
85
|
|
|
86
86
|
// Smart Organize (RFC 034, epic #1280). Present on both backends
|
|
87
87
|
// (`FilterService`/`LabelService`/… on DynamoDB, `FilterRepo`/`LabelRepo`/…
|
|
88
|
-
//
|
|
89
|
-
// either. The
|
|
88
|
+
// relationally) so the matching pipeline and filter CRUD run unchanged on
|
|
89
|
+
// either. The relational side has no TTL reaper for expired Temporary filters;
|
|
90
90
|
// match-time correctness gates on `expiresAt` (RFC 034 Decision 1.1), never
|
|
91
91
|
// on the row still existing, so the missing reaper is housekeeping only.
|
|
92
92
|
filter: IFilterRepository;
|
|
@@ -99,7 +99,7 @@ export interface RemitClient {
|
|
|
99
99
|
label: ILabelRepository;
|
|
100
100
|
messageLabel: IMessageLabelRepository;
|
|
101
101
|
|
|
102
|
-
// Atomic write set for a message save. Present on
|
|
102
|
+
// Atomic write set for a message save. Present on the relational backend (real
|
|
103
103
|
// transaction); absent on DynamoDB, where callers fall back to per-repo
|
|
104
104
|
// writes with that backend's own (non-transactional) guarantees.
|
|
105
105
|
unitOfWork?: IUnitOfWork;
|
|
@@ -123,16 +123,16 @@ export interface RemitClient {
|
|
|
123
123
|
|
|
124
124
|
// Pending placement-move markers (issue #1271). Present on both backends
|
|
125
125
|
// (`MessagePlacementMoveService` on DynamoDB, `MessagePlacementMoveRepo` on
|
|
126
|
-
//
|
|
126
|
+
// relationally) so a caller never needs to guess which backend is active. Used
|
|
127
127
|
// for read-time count prediction (epic #1281 invariant 4) and by the
|
|
128
128
|
// account-worker cascade delete. Written by the imap-worker's bulk
|
|
129
129
|
// body-sync path through this `placementMove`, so the placement producer
|
|
130
|
-
// runs on whatever backend is active — DynamoDB
|
|
130
|
+
// runs on whatever backend is active — DynamoDB or SQLite.
|
|
131
131
|
placementMove: IMessagePlacementMoveRepository;
|
|
132
132
|
|
|
133
133
|
// Pending flag-push markers (issue #1273). Present on both backends
|
|
134
134
|
// (`MessageFlagPushService` on DynamoDB, `MessageFlagPushRepo` on
|
|
135
|
-
//
|
|
135
|
+
// relationally). Used for read-time unseenCount prediction (epic #1281
|
|
136
136
|
// invariant 4), by the account-worker cascade delete, and by the periodic
|
|
137
137
|
// per-mailbox sync tick to re-arm a marker stuck `pending`. Unlike
|
|
138
138
|
// `placementMove`, this one IS written on both backends — `flagQueue`
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
_resetForTest,
|
|
8
|
+
getClient,
|
|
9
|
+
type RemitClient,
|
|
10
|
+
setClient,
|
|
11
|
+
} from "./data-client.js";
|
|
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
|
+
"quarantine",
|
|
27
|
+
"storage",
|
|
28
|
+
"search",
|
|
29
|
+
"secrets",
|
|
30
|
+
"bodySync",
|
|
31
|
+
"flagQueue",
|
|
32
|
+
"mailboxQueue",
|
|
33
|
+
"messageMove",
|
|
34
|
+
"outboxQueue",
|
|
35
|
+
"createConnectionScope",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
_resetForTest();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const withSqliteDbPath = async (run: () => Promise<void>): Promise<void> => {
|
|
43
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "remit-data-client-"));
|
|
45
|
+
process.env.SQLITE_DB_PATH = join(dir, "remit.db");
|
|
46
|
+
try {
|
|
47
|
+
await run();
|
|
48
|
+
} finally {
|
|
49
|
+
_resetForTest();
|
|
50
|
+
if (saved === undefined) delete process.env.SQLITE_DB_PATH;
|
|
51
|
+
else process.env.SQLITE_DB_PATH = saved;
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test("getClient() falls back to the in-package composition and constructs every service", async () => {
|
|
57
|
+
await withSqliteDbPath(async () => {
|
|
58
|
+
const client = await getClient();
|
|
59
|
+
|
|
60
|
+
for (const key of REQUIRED_KEYS) {
|
|
61
|
+
assert.ok(
|
|
62
|
+
client[key] !== undefined && client[key] !== null,
|
|
63
|
+
`RemitClient.${String(key)} must be defined on the fallback path`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
assert.equal(typeof client.createConnectionScope, "function");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("getClient() names setClient when nothing is registered and there is no database to open", async () => {
|
|
72
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
73
|
+
delete process.env.SQLITE_DB_PATH;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await assert.rejects(getClient(), /register one with setClient\(\)/);
|
|
77
|
+
} finally {
|
|
78
|
+
if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("getClient() returns the injected client without reading the environment", async () => {
|
|
83
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
84
|
+
delete process.env.SQLITE_DB_PATH;
|
|
85
|
+
|
|
86
|
+
const injected = { account: {} } as unknown as RemitClient;
|
|
87
|
+
setClient(injected);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const client = await getClient();
|
|
91
|
+
assert.equal(client, injected);
|
|
92
|
+
} finally {
|
|
93
|
+
_resetForTest();
|
|
94
|
+
if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -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
|
+
let clientPromise: Promise<RemitClient> | null = null;
|
|
9
|
+
let injected: RemitClient | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the client from the composition root. An adapter that lives outside
|
|
13
|
+
* this shared, open-core module — DynamoDB, or a Postgres adapter in the
|
|
14
|
+
* repository that deploys it — is never imported here; its composition root
|
|
15
|
+
* calls this before handling a request.
|
|
16
|
+
*/
|
|
17
|
+
export const setClient = (client: RemitClient): void => {
|
|
18
|
+
injected = client;
|
|
19
|
+
clientPromise = null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The SQLite composition this build contains, reached only by a process that
|
|
24
|
+
* registered no client of its own. The `import()` is dynamic so a bundler
|
|
25
|
+
* following the entry graph of a process that injects its client never reaches
|
|
26
|
+
* `@remit/drizzle-service` or drizzle-orm (both `external` for the Lambda
|
|
27
|
+
* esbuild build).
|
|
28
|
+
*
|
|
29
|
+
* `SQLITE_DB_PATH` is the precondition the composition needs, checked ahead of
|
|
30
|
+
* the import so its absence names `setClient` instead of failing on module
|
|
31
|
+
* resolution inside a bundle, or — worse — opening an empty database and
|
|
32
|
+
* serving an empty mailbox. It is a precondition, not a backend selection.
|
|
33
|
+
*/
|
|
34
|
+
const buildRelationalClient = async (): Promise<RemitClient> => {
|
|
35
|
+
if (!process.env.SQLITE_DB_PATH) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"no client registered — register one with setClient() from your composition root",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const composition = await import("./compose-sqlite.js");
|
|
41
|
+
return composition.buildSqliteClient();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const getClient = (): Promise<RemitClient> => {
|
|
45
|
+
if (!clientPromise) {
|
|
46
|
+
clientPromise = injected
|
|
47
|
+
? Promise.resolve(injected)
|
|
48
|
+
: buildRelationalClient();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return clientPromise;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Reset the singleton and any injected client — test use only. */
|
|
55
|
+
export const _resetForTest = (): void => {
|
|
56
|
+
clientPromise = null;
|
|
57
|
+
injected = null;
|
|
58
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
3
|
import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
|
|
4
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
4
|
+
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
5
5
|
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
6
6
|
import type {
|
|
7
7
|
AnchorPayload,
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
VectorRecord,
|
|
10
10
|
} from "@remit/search-service";
|
|
11
11
|
import { createMemoryVectorStore } from "@remit/search-service";
|
|
12
|
-
import type { RemitClient } from "./
|
|
12
|
+
import type { RemitClient } from "./data-client.js";
|
|
13
13
|
import {
|
|
14
14
|
applyOrganize,
|
|
15
15
|
matchOrganize,
|
|
@@ -487,7 +487,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
487
487
|
);
|
|
488
488
|
});
|
|
489
489
|
|
|
490
|
-
it("
|
|
490
|
+
it("rejects a body-content (HasWords) clause as a 400 rather than matching it against a preview", async () => {
|
|
491
491
|
const deps = vectorlessDeps([
|
|
492
492
|
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
493
493
|
]);
|
|
@@ -499,7 +499,12 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
499
499
|
anchorMessageId: "None",
|
|
500
500
|
literalClauses: [{ field: "HasWords", value: "invoice" }],
|
|
501
501
|
}),
|
|
502
|
-
|
|
502
|
+
(error: unknown) => {
|
|
503
|
+
assert.ok(error instanceof BadRequestError);
|
|
504
|
+
assert.equal(error.statusCode, 400);
|
|
505
|
+
assert.match(error.message, /HasWords/);
|
|
506
|
+
return true;
|
|
507
|
+
},
|
|
503
508
|
"the vector-free literal path must not silently narrow a body match to a preview",
|
|
504
509
|
);
|
|
505
510
|
assert.equal(deps.semanticUsed(), false);
|
package/src/service/organize.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
IFilterAnchorRepository,
|
|
4
4
|
OrganizeJobRequestItem,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
6
|
+
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
7
|
import { FilterClauseField, FilterState } from "@remit/domain-enums";
|
|
8
8
|
import {
|
|
9
9
|
buildMatchText,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
buildEmbeddingServiceFromEnv,
|
|
26
26
|
buildVectorStoreFromEnv,
|
|
27
27
|
} from "@remit/search-service/from-env";
|
|
28
|
-
import type { RemitClient } from "./
|
|
28
|
+
import type { RemitClient } from "./data-client.js";
|
|
29
29
|
import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -280,16 +280,14 @@ const matchSemantic = async (
|
|
|
280
280
|
* happened to be indexed. Body-content matching therefore requires the widen
|
|
281
281
|
* (vector) path — {@link matchSemantic} reconstructs body text from chunk
|
|
282
282
|
* previews there. This guard keeps the two matchers from diverging silently: a
|
|
283
|
-
* body-content clause reaching the vector-free path
|
|
284
|
-
* returning a wrong set.
|
|
285
|
-
* `HasWords` clause (the organize UI sends empty `literalClauses`, the filter
|
|
286
|
-
* builder emits none) — and stays a fail-fast for any future surface that does.
|
|
283
|
+
* body-content clause reaching the vector-free path is rejected instead of
|
|
284
|
+
* returning a wrong set.
|
|
287
285
|
*/
|
|
288
286
|
const assertNoBodyContentClause = (
|
|
289
287
|
clauses: OrganizePredicate["literalClauses"],
|
|
290
288
|
): void => {
|
|
291
289
|
if (clauses.some((clause) => clause.field === FilterClauseField.HasWords)) {
|
|
292
|
-
throw new
|
|
290
|
+
throw new BadRequestError(
|
|
293
291
|
"Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path",
|
|
294
292
|
);
|
|
295
293
|
}
|
|
@@ -33,14 +33,11 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
33
33
|
_resetSemanticCapabilityForTest();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("absorbs a missing-module failure on the self-host SQL
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
|
|
42
|
-
assert.equal(isSemanticSearchUnavailable(), true);
|
|
43
|
-
}
|
|
36
|
+
it("absorbs a missing-module failure on the self-host SQL backend and remembers it", () => {
|
|
37
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
38
|
+
assert.equal(isSemanticSearchUnavailable(), false);
|
|
39
|
+
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
|
|
40
|
+
assert.equal(isSemanticSearchUnavailable(), true);
|
|
44
41
|
});
|
|
45
42
|
|
|
46
43
|
it("absorbs a dlopen failure (musl loading a glibc extension)", () => {
|
|
@@ -53,16 +50,13 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
53
50
|
});
|
|
54
51
|
|
|
55
52
|
it("absorbs an embedding-model load failure (e2e-dev from source, HuggingFace fetch failed) and remembers it", () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
|
|
61
|
-
assert.equal(isSemanticSearchUnavailable(), true);
|
|
62
|
-
}
|
|
53
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
54
|
+
assert.equal(isSemanticSearchUnavailable(), false);
|
|
55
|
+
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
|
|
56
|
+
assert.equal(isSemanticSearchUnavailable(), true);
|
|
63
57
|
});
|
|
64
58
|
|
|
65
|
-
it("rethrows genuine query errors on the self-host SQL
|
|
59
|
+
it("rethrows genuine query errors on the self-host SQL backend", () => {
|
|
66
60
|
process.env.DATA_BACKEND = "sqlite";
|
|
67
61
|
assert.equal(
|
|
68
62
|
noteSemanticCapabilityAbsence(new Error("SQLITE_BUSY")),
|
|
@@ -35,7 +35,7 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
35
35
|
* the FTS/literal engine is the primary search surface on these profiles and is
|
|
36
36
|
* unaffected. The absence is remembered so subsequent requests short-circuit.
|
|
37
37
|
*
|
|
38
|
-
* Scoped to the self-host SQL
|
|
38
|
+
* Scoped to the self-host SQL backend: on AWS the pipeline (Bedrock +
|
|
39
39
|
* S3 Vectors) is bundled, so a module-resolution failure there is a broken
|
|
40
40
|
* deploy and must keep failing loud. Falling back to the FTS engine here
|
|
41
41
|
* instead was considered and rejected — it would fabricate relevance scores and
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AccountConfigRepo,
|
|
3
|
-
AccountExportRequestRepo,
|
|
4
|
-
AccountRepo,
|
|
5
|
-
AccountSettingRepo,
|
|
6
|
-
AddressRepo,
|
|
7
|
-
DrizzleEnvelopeRepository,
|
|
8
|
-
DrizzleFilterAnchorTransaction,
|
|
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
|
-
QuarantineRepo,
|
|
26
|
-
} from "@remit/drizzle-service";
|
|
27
|
-
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
28
|
-
import { env } from "expect-env";
|
|
29
|
-
import {
|
|
30
|
-
buildSharedDeps,
|
|
31
|
-
createRemitClient,
|
|
32
|
-
type RemitClient,
|
|
33
|
-
type RemitClientRepositories,
|
|
34
|
-
} from "./create-remit-client.js";
|
|
35
|
-
|
|
36
|
-
export const buildPostgresClient = (): RemitClient => {
|
|
37
|
-
const pgConnectionUrl = env.PG_CONNECTION_URL;
|
|
38
|
-
|
|
39
|
-
// One drizzle db instance shared across repos.
|
|
40
|
-
// The schema is registered for message-data tables; i4 repos use the same
|
|
41
|
-
// underlying connection and only need the builder API (no relational queries).
|
|
42
|
-
const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
|
|
43
|
-
const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
|
|
44
|
-
|
|
45
|
-
const messageDataDb = db as unknown as NodePgDatabase<
|
|
46
|
-
typeof messageDataSchema
|
|
47
|
-
>;
|
|
48
|
-
|
|
49
|
-
const repositories: RemitClientRepositories = {
|
|
50
|
-
accountConfig: new AccountConfigRepo(genericDb),
|
|
51
|
-
account: new AccountRepo(genericDb),
|
|
52
|
-
accountSetting: new AccountSettingRepo(genericDb),
|
|
53
|
-
address: new AddressRepo(genericDb),
|
|
54
|
-
mailbox: new MailboxRepo(genericDb),
|
|
55
|
-
mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
|
|
56
|
-
mailboxLock: new MailboxLockRepo(genericDb),
|
|
57
|
-
message: new DrizzleMessageRepository(messageDataDb),
|
|
58
|
-
messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
|
|
59
|
-
outboxMessage: new OutboxMessageRepo(genericDb),
|
|
60
|
-
threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
|
|
61
|
-
envelope: new DrizzleEnvelopeRepository(messageDataDb),
|
|
62
|
-
accountExportRequest: new AccountExportRequestRepo(genericDb),
|
|
63
|
-
quarantine: new QuarantineRepo(genericDb),
|
|
64
|
-
organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
|
|
65
|
-
placementMove: new MessagePlacementMoveRepo(genericDb),
|
|
66
|
-
flagPush: new MessageFlagPushRepo(genericDb),
|
|
67
|
-
filter: new FilterRepo(genericDb),
|
|
68
|
-
filterAnchor: new FilterAnchorRepo(genericDb),
|
|
69
|
-
filterAnchorTransaction: new DrizzleFilterAnchorTransaction(genericDb),
|
|
70
|
-
label: new LabelRepo(genericDb),
|
|
71
|
-
messageLabel: new MessageLabelRepo(genericDb),
|
|
72
|
-
unitOfWork: new DrizzleUnitOfWork(messageDataDb),
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return createRemitClient({ repositories, ...buildSharedDeps() });
|
|
76
|
-
};
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
_resetForTest,
|
|
5
|
-
getClient,
|
|
6
|
-
type RemitClient,
|
|
7
|
-
setClient,
|
|
8
|
-
} from "./dynamodb.js";
|
|
9
|
-
|
|
10
|
-
const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
|
|
11
|
-
"accountConfig",
|
|
12
|
-
"account",
|
|
13
|
-
"accountSetting",
|
|
14
|
-
"address",
|
|
15
|
-
"mailbox",
|
|
16
|
-
"mailboxSpecialUse",
|
|
17
|
-
"message",
|
|
18
|
-
"messageFlag",
|
|
19
|
-
"outboxMessage",
|
|
20
|
-
"threadMessage",
|
|
21
|
-
"envelope",
|
|
22
|
-
"accountExportRequest",
|
|
23
|
-
"quarantine",
|
|
24
|
-
"storage",
|
|
25
|
-
"search",
|
|
26
|
-
"secrets",
|
|
27
|
-
"bodySync",
|
|
28
|
-
"flagQueue",
|
|
29
|
-
"mailboxQueue",
|
|
30
|
-
"messageMove",
|
|
31
|
-
"outboxQueue",
|
|
32
|
-
"createConnectionScope",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
_resetForTest();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("getClient() with DATA_BACKEND=postgres constructs all services without throwing", async () => {
|
|
40
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
41
|
-
const savedPgUrl = process.env.PG_CONNECTION_URL;
|
|
42
|
-
|
|
43
|
-
process.env.DATA_BACKEND = "postgres";
|
|
44
|
-
process.env.PG_CONNECTION_URL =
|
|
45
|
-
"postgresql://remit:remit@localhost:5432/remit_test";
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const client = await getClient();
|
|
49
|
-
|
|
50
|
-
for (const key of REQUIRED_KEYS) {
|
|
51
|
-
assert.ok(
|
|
52
|
-
client[key] !== undefined && client[key] !== null,
|
|
53
|
-
`RemitClient.${key} must be defined on the postgres path`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
assert.equal(typeof client.createConnectionScope, "function");
|
|
58
|
-
} finally {
|
|
59
|
-
_resetForTest();
|
|
60
|
-
if (savedBackend === undefined) {
|
|
61
|
-
delete process.env.DATA_BACKEND;
|
|
62
|
-
} else {
|
|
63
|
-
process.env.DATA_BACKEND = savedBackend;
|
|
64
|
-
}
|
|
65
|
-
if (savedPgUrl === undefined) {
|
|
66
|
-
delete process.env.PG_CONNECTION_URL;
|
|
67
|
-
} else {
|
|
68
|
-
process.env.PG_CONNECTION_URL = savedPgUrl;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("getClient() without DATA_BACKEND throws until a client is registered", () => {
|
|
74
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
75
|
-
delete process.env.DATA_BACKEND;
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
assert.throws(() => getClient(), /no DynamoDB client registered/);
|
|
79
|
-
} finally {
|
|
80
|
-
if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("getClient() without DATA_BACKEND returns the injected client", async () => {
|
|
85
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
86
|
-
delete process.env.DATA_BACKEND;
|
|
87
|
-
|
|
88
|
-
const injected = { account: {} } as unknown as RemitClient;
|
|
89
|
-
setClient(injected);
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const client = await getClient();
|
|
93
|
-
assert.equal(client, injected);
|
|
94
|
-
} finally {
|
|
95
|
-
_resetForTest();
|
|
96
|
-
if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
|
|
97
|
-
}
|
|
98
|
-
});
|
package/src/service/dynamodb.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
let clientPromise: Promise<RemitClient> | null = null;
|
|
9
|
-
let injected: RemitClient | null = null;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Register the DynamoDB-backed client from the composition root. The DynamoDB
|
|
13
|
-
* composition lives outside this shared, open-core module and is never imported
|
|
14
|
-
* here. Every DynamoDB entry point calls this before handling a request. The
|
|
15
|
-
* relational backends compose in-package below and never touch this seam.
|
|
16
|
-
*/
|
|
17
|
-
export const setClient = (client: RemitClient): void => {
|
|
18
|
-
injected = client;
|
|
19
|
-
clientPromise = null;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// The API process and the imap-worker share this composition root. The
|
|
23
|
-
// relational backends are loaded through a lazy `import()` of their own
|
|
24
|
-
// in-package composition module, so the module a given deploy never runs never
|
|
25
|
-
// enters its graph: `@remit/drizzle-service`/`drizzle-orm` stay out of a
|
|
26
|
-
// Lambda bundle (both `external` for the Lambda esbuild build;
|
|
27
|
-
// `remit-lambda-bundles.test.ts` enforces this). The DynamoDB backend is
|
|
28
|
-
// injected by the composition root through `setClient`, so this module carries
|
|
29
|
-
// no import of the DynamoDB composition.
|
|
30
|
-
export const getClient = (): Promise<RemitClient> => {
|
|
31
|
-
if (!clientPromise) {
|
|
32
|
-
if (process.env.DATA_BACKEND === "postgres") {
|
|
33
|
-
clientPromise = import("./compose-postgres.js").then((m) =>
|
|
34
|
-
m.buildPostgresClient(),
|
|
35
|
-
);
|
|
36
|
-
} else if (process.env.DATA_BACKEND === "sqlite") {
|
|
37
|
-
clientPromise = import("./compose-sqlite.js").then((m) =>
|
|
38
|
-
m.buildSqliteClient(),
|
|
39
|
-
);
|
|
40
|
-
} else {
|
|
41
|
-
if (!injected) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
"no DynamoDB client registered — register one with setClient() from your composition root",
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
clientPromise = Promise.resolve(injected);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return clientPromise;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** Reset the singleton and any injected client — test use only. */
|
|
54
|
-
export const _resetForTest = (): void => {
|
|
55
|
-
clientPromise = null;
|
|
56
|
-
injected = null;
|
|
57
|
-
};
|