@remit/drizzle-service 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.
- package/drizzle.config.ts +15 -0
- package/package.json +52 -0
- package/src/db.ts +13 -0
- package/src/dialect.ts +13 -0
- package/src/error.ts +37 -0
- package/src/id.ts +50 -0
- package/src/index.ts +62 -0
- package/src/pagination.ts +29 -0
- package/src/repos/cascade-delete.sqlite.test.ts +159 -0
- package/src/repos/cascade-delete.test.ts +423 -0
- package/src/repos/cascade-delete.ts +219 -0
- package/src/repos/envelope.sqlite.test.ts +94 -0
- package/src/repos/envelope.test.ts +225 -0
- package/src/repos/envelope.ts +342 -0
- package/src/repos/filter-anchor.test.ts +131 -0
- package/src/repos/filter-anchor.ts +105 -0
- package/src/repos/filter.test.ts +279 -0
- package/src/repos/filter.ts +253 -0
- package/src/repos/i4-account-config.test.ts +105 -0
- package/src/repos/i4-account-config.ts +257 -0
- package/src/repos/i4-account-export-request.ts +141 -0
- package/src/repos/i4-account-setting.test.ts +94 -0
- package/src/repos/i4-account-setting.ts +92 -0
- package/src/repos/i4-account.test.ts +223 -0
- package/src/repos/i4-account.ts +380 -0
- package/src/repos/i4-address-wellknown.ts +31 -0
- package/src/repos/i4-address.test.ts +358 -0
- package/src/repos/i4-address.ts +613 -0
- package/src/repos/i4-mailbox-lock.test.ts +368 -0
- package/src/repos/i4-mailbox-lock.ts +140 -0
- package/src/repos/i4-mailbox-special-use.ts +165 -0
- package/src/repos/i4-mailbox.test.ts +188 -0
- package/src/repos/i4-mailbox.ts +347 -0
- package/src/repos/i4-message-flag-push.test.ts +189 -0
- package/src/repos/i4-message-flag-push.ts +173 -0
- package/src/repos/i4-message-placement-move.test.ts +135 -0
- package/src/repos/i4-message-placement-move.ts +144 -0
- package/src/repos/i4-organize-job-request.ts +142 -0
- package/src/repos/i4-outbox-message.test.ts +298 -0
- package/src/repos/i4-outbox-message.ts +299 -0
- package/src/repos/label.conformance.sqlite.test.ts +23 -0
- package/src/repos/label.conformance.test.ts +19 -0
- package/src/repos/label.ts +125 -0
- package/src/repos/mappers.ts +171 -0
- package/src/repos/message-flag.ts +162 -0
- package/src/repos/message-label.test.ts +110 -0
- package/src/repos/message-label.ts +96 -0
- package/src/repos/message.sqlite.test.ts +118 -0
- package/src/repos/message.test.ts +488 -0
- package/src/repos/message.ts +558 -0
- package/src/repos/serialized-writes.sqlite.test.ts +199 -0
- package/src/repos/test-helpers.ts +222 -0
- package/src/repos/thread-message.sqlite.test.ts +198 -0
- package/src/repos/thread-message.test.ts +744 -0
- package/src/repos/thread-message.ts +832 -0
- package/src/repos/thread-search-predicates.ts +79 -0
- package/src/repos/unit-of-work.sqlite.test.ts +138 -0
- package/src/repos/unit-of-work.test.ts +105 -0
- package/src/repos/unit-of-work.ts +31 -0
- package/src/schema/active-entities.ts +19 -0
- package/src/schema/i4-account-config.ts +4 -0
- package/src/schema/i4-account-export-request.ts +3 -0
- package/src/schema/i4-account-setting.ts +3 -0
- package/src/schema/i4-address.ts +3 -0
- package/src/schema/i4-mailbox-lock.ts +3 -0
- package/src/schema/i4-mailbox.ts +4 -0
- package/src/schema/i4-message-flag-push.ts +3 -0
- package/src/schema/i4-message-placement-move.ts +3 -0
- package/src/schema/i4-organize-job-request.ts +1 -0
- package/src/schema/i4-outbox-message.ts +3 -0
- package/src/schema/message-data.ts +44 -0
- package/src/schema/outbox.ts +74 -0
- package/src/schema/thread-message.ts +3 -0
- package/src/schema-full-sqlite.ts +11 -0
- package/src/schema-full.ts +16 -0
- package/src/schema.ts +28 -0
- package/src/sqlite-client.ts +52 -0
- package/src/test-db-sqlite.ts +75 -0
- package/src/test-db.ts +76 -0
- package/src/tx.ts +208 -0
- package/src/vps-migrations-drift.test.ts +34 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
|
+
import { MessageFlagPushRepo } from "./i4-message-flag-push.js";
|
|
5
|
+
|
|
6
|
+
describe("MessageFlagPushRepo (Postgres counterpart to MessageFlagPushService)", () => {
|
|
7
|
+
let db: TestDb;
|
|
8
|
+
let close: () => Promise<void>;
|
|
9
|
+
let repo: MessageFlagPushRepo;
|
|
10
|
+
|
|
11
|
+
const seedInput = (
|
|
12
|
+
overrides: Partial<{
|
|
13
|
+
messageId: string;
|
|
14
|
+
flagName: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
mailboxId: string;
|
|
18
|
+
operation: "add" | "remove";
|
|
19
|
+
}> = {},
|
|
20
|
+
) => ({
|
|
21
|
+
messageId: randomId(),
|
|
22
|
+
flagName: "\\Seen",
|
|
23
|
+
accountId: randomId(),
|
|
24
|
+
accountConfigId: randomId(),
|
|
25
|
+
mailboxId: randomId(),
|
|
26
|
+
operation: "add" as const,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
({ db, close } = await createTestDb());
|
|
32
|
+
repo = new MessageFlagPushRepo(db as never);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
after(async () => {
|
|
36
|
+
await close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("find returns null when no marker exists — the normal steady state", async () => {
|
|
40
|
+
const found = await repo.find(randomId(), "\\Seen");
|
|
41
|
+
assert.equal(found, null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("put then find round-trips the marker, using the composite (messageId, flagName) key, defaulting state to pending", async () => {
|
|
45
|
+
const input = seedInput();
|
|
46
|
+
await repo.put(input);
|
|
47
|
+
|
|
48
|
+
const found = await repo.find(input.messageId, input.flagName);
|
|
49
|
+
assert.ok(found);
|
|
50
|
+
assert.equal(found?.messageId, input.messageId);
|
|
51
|
+
assert.equal(found?.flagName, input.flagName);
|
|
52
|
+
assert.equal(found?.mailboxId, input.mailboxId);
|
|
53
|
+
assert.equal(found?.operation, "add");
|
|
54
|
+
assert.equal(found?.state, "pending");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a pending Seen marker and a pending Flagged marker on the SAME message coexist independently", async () => {
|
|
58
|
+
const messageId = randomId();
|
|
59
|
+
await repo.put(
|
|
60
|
+
seedInput({ messageId, flagName: "\\Seen", operation: "add" }),
|
|
61
|
+
);
|
|
62
|
+
await repo.put(
|
|
63
|
+
seedInput({ messageId, flagName: "\\Flagged", operation: "remove" }),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const seenMarker = await repo.find(messageId, "\\Seen");
|
|
67
|
+
const starMarker = await repo.find(messageId, "\\Flagged");
|
|
68
|
+
|
|
69
|
+
assert.equal(seenMarker?.operation, "add");
|
|
70
|
+
assert.equal(starMarker?.operation, "remove");
|
|
71
|
+
|
|
72
|
+
await repo.put(
|
|
73
|
+
seedInput({ messageId, flagName: "\\Seen", operation: "remove" }),
|
|
74
|
+
);
|
|
75
|
+
const starMarkerAfter = await repo.find(messageId, "\\Flagged");
|
|
76
|
+
assert.equal(
|
|
77
|
+
starMarkerAfter?.operation,
|
|
78
|
+
"remove",
|
|
79
|
+
"the star marker must survive a later flip of the read-state field",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("updateState advances the state engine without touching other fields", async () => {
|
|
84
|
+
const input = seedInput();
|
|
85
|
+
await repo.put(input);
|
|
86
|
+
|
|
87
|
+
const queued = await repo.updateState(
|
|
88
|
+
input.messageId,
|
|
89
|
+
input.flagName,
|
|
90
|
+
"queued",
|
|
91
|
+
);
|
|
92
|
+
assert.equal(queued.state, "queued");
|
|
93
|
+
assert.equal(queued.operation, input.operation);
|
|
94
|
+
|
|
95
|
+
const processing = await repo.updateState(
|
|
96
|
+
input.messageId,
|
|
97
|
+
input.flagName,
|
|
98
|
+
"processing",
|
|
99
|
+
);
|
|
100
|
+
assert.equal(processing.state, "processing");
|
|
101
|
+
|
|
102
|
+
const found = await repo.find(input.messageId, input.flagName);
|
|
103
|
+
assert.equal(found?.state, "processing");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("updateState throws on a marker that does not exist", async () => {
|
|
107
|
+
await assert.rejects(() =>
|
|
108
|
+
repo.updateState(randomId(), "\\Seen", "queued"),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("put resets state back to pending — a fresh flip always starts a new lifecycle", async () => {
|
|
113
|
+
const input = seedInput();
|
|
114
|
+
await repo.put(input);
|
|
115
|
+
await repo.updateState(input.messageId, input.flagName, "processing");
|
|
116
|
+
|
|
117
|
+
await repo.put(
|
|
118
|
+
seedInput({ messageId: input.messageId, flagName: input.flagName }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const found = await repo.find(input.messageId, input.flagName);
|
|
122
|
+
assert.equal(found?.state, "pending");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("put is idempotent — a later flip of the SAME field replaces the marker", async () => {
|
|
126
|
+
const messageId = randomId();
|
|
127
|
+
const flagName = "\\Seen";
|
|
128
|
+
|
|
129
|
+
await repo.put(seedInput({ messageId, flagName, operation: "add" }));
|
|
130
|
+
await repo.put(seedInput({ messageId, flagName, operation: "remove" }));
|
|
131
|
+
|
|
132
|
+
const found = await repo.find(messageId, flagName);
|
|
133
|
+
assert.equal(found?.operation, "remove");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("delete clears the marker (confirmed push / external delete)", async () => {
|
|
137
|
+
const input = seedInput();
|
|
138
|
+
await repo.put(input);
|
|
139
|
+
await repo.delete(input.messageId, input.flagName);
|
|
140
|
+
|
|
141
|
+
const found = await repo.find(input.messageId, input.flagName);
|
|
142
|
+
assert.equal(found, null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("delete on an absent marker is a no-op, never throws", async () => {
|
|
146
|
+
await assert.doesNotReject(() => repo.delete(randomId(), "\\Seen"));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("listByAccountId returns every pending marker for the account, none for another", async () => {
|
|
150
|
+
const accountId = randomId();
|
|
151
|
+
const otherAccountId = randomId();
|
|
152
|
+
|
|
153
|
+
const first = seedInput({ accountId });
|
|
154
|
+
const second = seedInput({ accountId, flagName: "\\Flagged" });
|
|
155
|
+
const foreign = seedInput({ accountId: otherAccountId });
|
|
156
|
+
|
|
157
|
+
await repo.put(first);
|
|
158
|
+
await repo.put(second);
|
|
159
|
+
await repo.put(foreign);
|
|
160
|
+
|
|
161
|
+
const markers = await repo.listByAccountId(accountId);
|
|
162
|
+
const messageIds = markers.map((m) => m.messageId).sort();
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(messageIds, [first.messageId, second.messageId].sort());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("listByAccountId returns an empty list for an account with no pending pushes", async () => {
|
|
168
|
+
const markers = await repo.listByAccountId(randomId());
|
|
169
|
+
assert.deepEqual(markers, []);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("listByMailboxId returns every pending marker for the mailbox, none for another", async () => {
|
|
173
|
+
const mailboxId = randomId();
|
|
174
|
+
const otherMailboxId = randomId();
|
|
175
|
+
|
|
176
|
+
const first = seedInput({ mailboxId });
|
|
177
|
+
const second = seedInput({ mailboxId, flagName: "\\Flagged" });
|
|
178
|
+
const foreign = seedInput({ mailboxId: otherMailboxId });
|
|
179
|
+
|
|
180
|
+
await repo.put(first);
|
|
181
|
+
await repo.put(second);
|
|
182
|
+
await repo.put(foreign);
|
|
183
|
+
|
|
184
|
+
const markers = await repo.listByMailboxId(mailboxId);
|
|
185
|
+
const messageIds = markers.map((m) => m.messageId).sort();
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(messageIds, [first.messageId, second.messageId].sort());
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
|
|
4
|
+
|
|
5
|
+
type DB = NodePgDatabase<Record<string, unknown>>;
|
|
6
|
+
|
|
7
|
+
export type FlagPushOperation = "add" | "remove";
|
|
8
|
+
export type MessageFlagPushState =
|
|
9
|
+
| "pending"
|
|
10
|
+
| "queued"
|
|
11
|
+
| "processing"
|
|
12
|
+
| "processed";
|
|
13
|
+
|
|
14
|
+
export interface MessageFlagPushItem {
|
|
15
|
+
messageId: string;
|
|
16
|
+
flagName: string;
|
|
17
|
+
accountId: string;
|
|
18
|
+
accountConfigId: string;
|
|
19
|
+
mailboxId: string;
|
|
20
|
+
operation: FlagPushOperation;
|
|
21
|
+
state: MessageFlagPushState;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PutMessageFlagPushInput = Omit<
|
|
27
|
+
MessageFlagPushItem,
|
|
28
|
+
"state" | "createdAt" | "updatedAt"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
const DEFAULT_STATE: MessageFlagPushState = "pending";
|
|
32
|
+
|
|
33
|
+
function rowToItem(
|
|
34
|
+
row: typeof messageFlagPushTable.$inferSelect,
|
|
35
|
+
): MessageFlagPushItem {
|
|
36
|
+
return {
|
|
37
|
+
messageId: row.messageId,
|
|
38
|
+
flagName: row.flagName,
|
|
39
|
+
accountId: row.accountId,
|
|
40
|
+
accountConfigId: row.accountConfigId,
|
|
41
|
+
mailboxId: row.mailboxId,
|
|
42
|
+
operation: row.operation,
|
|
43
|
+
state: row.state,
|
|
44
|
+
createdAt: row.createdAt,
|
|
45
|
+
updatedAt: row.updatedAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Postgres counterpart to `MessageFlagPushService` (remit-electrodb-service).
|
|
51
|
+
* Same public shape (`put`/`find`/`updateState`/`delete`/`listByAccountId`/
|
|
52
|
+
* `listByMailboxId`) so both backends satisfy the same structural contract
|
|
53
|
+
* for read-time unseenCount prediction (issue #1273, epic #1281 invariant 4)
|
|
54
|
+
* and the account-worker cascade delete.
|
|
55
|
+
*
|
|
56
|
+
* Keyed by (`messageId`, `flagName`) — a message can carry an independent
|
|
57
|
+
* pending read-state marker and pending star marker at once (epic FAQ: "a
|
|
58
|
+
* flag flip replaces a pending flag flip", scoped per field).
|
|
59
|
+
*/
|
|
60
|
+
export class MessageFlagPushRepo {
|
|
61
|
+
constructor(private db: DB) {}
|
|
62
|
+
|
|
63
|
+
put = async (
|
|
64
|
+
input: PutMessageFlagPushInput,
|
|
65
|
+
): Promise<MessageFlagPushItem> => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
// A fresh put ALWAYS resets state to `pending` (the field's default) —
|
|
68
|
+
// a new flip decision starts a new lifecycle regardless of whatever
|
|
69
|
+
// state the row it replaces was in.
|
|
70
|
+
const [row] = await this.db
|
|
71
|
+
.insert(messageFlagPushTable)
|
|
72
|
+
.values({
|
|
73
|
+
...input,
|
|
74
|
+
state: DEFAULT_STATE,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
})
|
|
78
|
+
.onConflictDoUpdate({
|
|
79
|
+
target: [messageFlagPushTable.messageId, messageFlagPushTable.flagName],
|
|
80
|
+
set: {
|
|
81
|
+
accountId: input.accountId,
|
|
82
|
+
accountConfigId: input.accountConfigId,
|
|
83
|
+
mailboxId: input.mailboxId,
|
|
84
|
+
operation: input.operation,
|
|
85
|
+
state: DEFAULT_STATE,
|
|
86
|
+
updatedAt: now,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
.returning();
|
|
90
|
+
if (!row) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Failed to upsert MessageFlagPush: ${input.messageId}/${input.flagName}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return rowToItem(row);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
find = async (
|
|
99
|
+
messageId: string,
|
|
100
|
+
flagName: string,
|
|
101
|
+
): Promise<MessageFlagPushItem | null> => {
|
|
102
|
+
const [row] = await this.db
|
|
103
|
+
.select()
|
|
104
|
+
.from(messageFlagPushTable)
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
eq(messageFlagPushTable.messageId, messageId),
|
|
108
|
+
eq(messageFlagPushTable.flagName, flagName),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
return row ? rowToItem(row) : null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Advance the marker's state engine. Never touches any other field — a
|
|
116
|
+
* state transition is not a new decision, so `operation`/`mailboxId` must
|
|
117
|
+
* not change here (a genuinely new decision goes through `put`, which
|
|
118
|
+
* fully replaces the row).
|
|
119
|
+
*/
|
|
120
|
+
updateState = async (
|
|
121
|
+
messageId: string,
|
|
122
|
+
flagName: string,
|
|
123
|
+
state: MessageFlagPushState,
|
|
124
|
+
): Promise<MessageFlagPushItem> => {
|
|
125
|
+
const [row] = await this.db
|
|
126
|
+
.update(messageFlagPushTable)
|
|
127
|
+
.set({ state, updatedAt: Date.now() })
|
|
128
|
+
.where(
|
|
129
|
+
and(
|
|
130
|
+
eq(messageFlagPushTable.messageId, messageId),
|
|
131
|
+
eq(messageFlagPushTable.flagName, flagName),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
.returning();
|
|
135
|
+
if (!row) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Cannot update state on a MessageFlagPush that does not exist: ${messageId}/${flagName}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return rowToItem(row);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
delete = async (messageId: string, flagName: string): Promise<void> => {
|
|
144
|
+
await this.db
|
|
145
|
+
.delete(messageFlagPushTable)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(messageFlagPushTable.messageId, messageId),
|
|
149
|
+
eq(messageFlagPushTable.flagName, flagName),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
listByAccountId = async (
|
|
155
|
+
accountId: string,
|
|
156
|
+
): Promise<MessageFlagPushItem[]> => {
|
|
157
|
+
const rows = await this.db
|
|
158
|
+
.select()
|
|
159
|
+
.from(messageFlagPushTable)
|
|
160
|
+
.where(eq(messageFlagPushTable.accountId, accountId));
|
|
161
|
+
return rows.map(rowToItem);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
listByMailboxId = async (
|
|
165
|
+
mailboxId: string,
|
|
166
|
+
): Promise<MessageFlagPushItem[]> => {
|
|
167
|
+
const rows = await this.db
|
|
168
|
+
.select()
|
|
169
|
+
.from(messageFlagPushTable)
|
|
170
|
+
.where(eq(messageFlagPushTable.mailboxId, mailboxId));
|
|
171
|
+
return rows.map(rowToItem);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
|
+
import { MessagePlacementMoveRepo } from "./i4-message-placement-move.js";
|
|
5
|
+
|
|
6
|
+
describe("MessagePlacementMoveRepo (Postgres counterpart to MessagePlacementMoveService)", () => {
|
|
7
|
+
let db: TestDb;
|
|
8
|
+
let close: () => Promise<void>;
|
|
9
|
+
let repo: MessagePlacementMoveRepo;
|
|
10
|
+
|
|
11
|
+
const seedInput = (
|
|
12
|
+
overrides: Partial<{
|
|
13
|
+
messageId: string;
|
|
14
|
+
accountId: string;
|
|
15
|
+
accountConfigId: string;
|
|
16
|
+
sourceMailboxId: string;
|
|
17
|
+
destinationMailboxId: string;
|
|
18
|
+
}> = {},
|
|
19
|
+
) => ({
|
|
20
|
+
messageId: randomId(),
|
|
21
|
+
accountId: randomId(),
|
|
22
|
+
accountConfigId: randomId(),
|
|
23
|
+
sourceMailboxId: randomId(),
|
|
24
|
+
destinationMailboxId: randomId(),
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
before(async () => {
|
|
29
|
+
({ db, close } = await createTestDb());
|
|
30
|
+
repo = new MessagePlacementMoveRepo(db as never);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(async () => {
|
|
34
|
+
await close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("find returns null when no marker exists — the normal steady state", async () => {
|
|
38
|
+
const found = await repo.find(randomId());
|
|
39
|
+
assert.equal(found, null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("put then find round-trips the marker, using the given messageId as the primary key, defaulting state to pending", async () => {
|
|
43
|
+
const input = seedInput();
|
|
44
|
+
await repo.put(input);
|
|
45
|
+
|
|
46
|
+
const found = await repo.find(input.messageId);
|
|
47
|
+
assert.ok(found);
|
|
48
|
+
assert.equal(found?.messageId, input.messageId);
|
|
49
|
+
assert.equal(found?.sourceMailboxId, input.sourceMailboxId);
|
|
50
|
+
assert.equal(found?.destinationMailboxId, input.destinationMailboxId);
|
|
51
|
+
assert.equal(found?.state, "pending");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("updateState advances the state engine without touching other fields", async () => {
|
|
55
|
+
const input = seedInput();
|
|
56
|
+
await repo.put(input);
|
|
57
|
+
|
|
58
|
+
const queued = await repo.updateState(input.messageId, "queued");
|
|
59
|
+
assert.equal(queued.state, "queued");
|
|
60
|
+
assert.equal(queued.destinationMailboxId, input.destinationMailboxId);
|
|
61
|
+
|
|
62
|
+
const processing = await repo.updateState(input.messageId, "processing");
|
|
63
|
+
assert.equal(processing.state, "processing");
|
|
64
|
+
|
|
65
|
+
const found = await repo.find(input.messageId);
|
|
66
|
+
assert.equal(found?.state, "processing");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("updateState throws on a marker that does not exist", async () => {
|
|
70
|
+
await assert.rejects(() => repo.updateState(randomId(), "queued"));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("put resets state back to pending — a fresh decision always starts a new lifecycle", async () => {
|
|
74
|
+
const input = seedInput();
|
|
75
|
+
await repo.put(input);
|
|
76
|
+
await repo.updateState(input.messageId, "processing");
|
|
77
|
+
|
|
78
|
+
await repo.put(seedInput({ messageId: input.messageId }));
|
|
79
|
+
|
|
80
|
+
const found = await repo.find(input.messageId);
|
|
81
|
+
assert.equal(found?.state, "pending");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("put is idempotent — a later decision replaces the marker (later intent wins locally)", async () => {
|
|
85
|
+
const messageId = randomId();
|
|
86
|
+
const firstDestination = randomId();
|
|
87
|
+
const secondDestination = randomId();
|
|
88
|
+
|
|
89
|
+
await repo.put(
|
|
90
|
+
seedInput({ messageId, destinationMailboxId: firstDestination }),
|
|
91
|
+
);
|
|
92
|
+
await repo.put(
|
|
93
|
+
seedInput({ messageId, destinationMailboxId: secondDestination }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const found = await repo.find(messageId);
|
|
97
|
+
assert.equal(found?.destinationMailboxId, secondDestination);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("delete clears the marker (confirmed move / superseded / external delete)", async () => {
|
|
101
|
+
const input = seedInput();
|
|
102
|
+
await repo.put(input);
|
|
103
|
+
await repo.delete(input.messageId);
|
|
104
|
+
|
|
105
|
+
const found = await repo.find(input.messageId);
|
|
106
|
+
assert.equal(found, null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("delete on an absent marker is a no-op, never throws", async () => {
|
|
110
|
+
await assert.doesNotReject(() => repo.delete(randomId()));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("listByAccountId returns every pending marker for the account, none for another", async () => {
|
|
114
|
+
const accountId = randomId();
|
|
115
|
+
const otherAccountId = randomId();
|
|
116
|
+
|
|
117
|
+
const first = seedInput({ accountId });
|
|
118
|
+
const second = seedInput({ accountId });
|
|
119
|
+
const foreign = seedInput({ accountId: otherAccountId });
|
|
120
|
+
|
|
121
|
+
await repo.put(first);
|
|
122
|
+
await repo.put(second);
|
|
123
|
+
await repo.put(foreign);
|
|
124
|
+
|
|
125
|
+
const markers = await repo.listByAccountId(accountId);
|
|
126
|
+
const messageIds = markers.map((m) => m.messageId).sort();
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(messageIds, [first.messageId, second.messageId].sort());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("listByAccountId returns an empty list for an account with no pending moves", async () => {
|
|
132
|
+
const markers = await repo.listByAccountId(randomId());
|
|
133
|
+
assert.deepEqual(markers, []);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
|
|
4
|
+
|
|
5
|
+
type DB = NodePgDatabase<Record<string, unknown>>;
|
|
6
|
+
|
|
7
|
+
export type MessagePlacementMoveState =
|
|
8
|
+
| "pending"
|
|
9
|
+
| "queued"
|
|
10
|
+
| "processing"
|
|
11
|
+
| "processed";
|
|
12
|
+
|
|
13
|
+
export interface MessagePlacementMoveItem {
|
|
14
|
+
messageId: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
sourceMailboxId: string;
|
|
18
|
+
destinationMailboxId: string;
|
|
19
|
+
state: MessagePlacementMoveState;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PutMessagePlacementMoveInput = Omit<
|
|
25
|
+
MessagePlacementMoveItem,
|
|
26
|
+
"state" | "createdAt" | "updatedAt"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
const DEFAULT_STATE: MessagePlacementMoveState = "pending";
|
|
30
|
+
|
|
31
|
+
function rowToItem(
|
|
32
|
+
row: typeof messagePlacementMoveTable.$inferSelect,
|
|
33
|
+
): MessagePlacementMoveItem {
|
|
34
|
+
return {
|
|
35
|
+
messageId: row.messageId,
|
|
36
|
+
accountId: row.accountId,
|
|
37
|
+
accountConfigId: row.accountConfigId,
|
|
38
|
+
sourceMailboxId: row.sourceMailboxId,
|
|
39
|
+
destinationMailboxId: row.destinationMailboxId,
|
|
40
|
+
state: row.state,
|
|
41
|
+
createdAt: row.createdAt,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Postgres counterpart to `MessagePlacementMoveService` (remit-electrodb-service).
|
|
48
|
+
* Same public shape (`put`/`find`/`delete`/`listByAccountId`) so both backends
|
|
49
|
+
* satisfy the same structural contract for read-time count prediction
|
|
50
|
+
* (`mailbox.ts`'s `loadPendingMoves`, epic #1281 invariant 4).
|
|
51
|
+
*
|
|
52
|
+
* `messageId` is the marker's primary key — always our internal message id,
|
|
53
|
+
* NEVER a fresh generated one. The generated `messagePlacementMoveTable`
|
|
54
|
+
* schema defaults `messageId` via `$defaultFn` (matching every other
|
|
55
|
+
* @key-scalar column the emitter sees) — that default must never fire here,
|
|
56
|
+
* so every insert below supplies `messageId` explicitly.
|
|
57
|
+
*/
|
|
58
|
+
export class MessagePlacementMoveRepo {
|
|
59
|
+
constructor(private db: DB) {}
|
|
60
|
+
|
|
61
|
+
put = async (
|
|
62
|
+
input: PutMessagePlacementMoveInput,
|
|
63
|
+
): Promise<MessagePlacementMoveItem> => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
// A fresh put ALWAYS resets state to `pending` (the field's default) —
|
|
66
|
+
// a new placement decision starts a new lifecycle regardless of
|
|
67
|
+
// whatever state the row it replaces was in.
|
|
68
|
+
const [row] = await this.db
|
|
69
|
+
.insert(messagePlacementMoveTable)
|
|
70
|
+
.values({
|
|
71
|
+
...input,
|
|
72
|
+
state: DEFAULT_STATE,
|
|
73
|
+
createdAt: now,
|
|
74
|
+
updatedAt: now,
|
|
75
|
+
})
|
|
76
|
+
.onConflictDoUpdate({
|
|
77
|
+
target: messagePlacementMoveTable.messageId,
|
|
78
|
+
set: {
|
|
79
|
+
accountId: input.accountId,
|
|
80
|
+
accountConfigId: input.accountConfigId,
|
|
81
|
+
sourceMailboxId: input.sourceMailboxId,
|
|
82
|
+
destinationMailboxId: input.destinationMailboxId,
|
|
83
|
+
state: DEFAULT_STATE,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
.returning();
|
|
88
|
+
if (!row) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Failed to upsert MessagePlacementMove: ${input.messageId}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return rowToItem(row);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
find = async (
|
|
97
|
+
messageId: string,
|
|
98
|
+
): Promise<MessagePlacementMoveItem | null> => {
|
|
99
|
+
const [row] = await this.db
|
|
100
|
+
.select()
|
|
101
|
+
.from(messagePlacementMoveTable)
|
|
102
|
+
.where(eq(messagePlacementMoveTable.messageId, messageId));
|
|
103
|
+
return row ? rowToItem(row) : null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Advance the marker's state engine. Never touches any other field — a
|
|
108
|
+
* state transition is not a new decision, so the mailbox ids must not
|
|
109
|
+
* change here (a genuinely new decision goes through `put`, which fully
|
|
110
|
+
* replaces the row).
|
|
111
|
+
*/
|
|
112
|
+
updateState = async (
|
|
113
|
+
messageId: string,
|
|
114
|
+
state: MessagePlacementMoveState,
|
|
115
|
+
): Promise<MessagePlacementMoveItem> => {
|
|
116
|
+
const [row] = await this.db
|
|
117
|
+
.update(messagePlacementMoveTable)
|
|
118
|
+
.set({ state, updatedAt: Date.now() })
|
|
119
|
+
.where(eq(messagePlacementMoveTable.messageId, messageId))
|
|
120
|
+
.returning();
|
|
121
|
+
if (!row) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Cannot update state on a MessagePlacementMove that does not exist: ${messageId}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return rowToItem(row);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
delete = async (messageId: string): Promise<void> => {
|
|
130
|
+
await this.db
|
|
131
|
+
.delete(messagePlacementMoveTable)
|
|
132
|
+
.where(eq(messagePlacementMoveTable.messageId, messageId));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
listByAccountId = async (
|
|
136
|
+
accountId: string,
|
|
137
|
+
): Promise<MessagePlacementMoveItem[]> => {
|
|
138
|
+
const rows = await this.db
|
|
139
|
+
.select()
|
|
140
|
+
.from(messagePlacementMoveTable)
|
|
141
|
+
.where(eq(messagePlacementMoveTable.accountId, accountId));
|
|
142
|
+
return rows.map(rowToItem);
|
|
143
|
+
};
|
|
144
|
+
}
|