@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,358 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { after, before, describe, test } from "node:test";
|
|
4
|
+
import { envelopeAddressId } from "../id.js";
|
|
5
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
6
|
+
import { AddressRepo } from "./i4-address.js";
|
|
7
|
+
|
|
8
|
+
function makeAddressInput(accountConfigId: string, email = "test@example.com") {
|
|
9
|
+
const [localPart, domain] = email.split("@");
|
|
10
|
+
return {
|
|
11
|
+
addressId: randomId(),
|
|
12
|
+
accountConfigId,
|
|
13
|
+
localPart,
|
|
14
|
+
domain,
|
|
15
|
+
normalizedEmail: email.toLowerCase(),
|
|
16
|
+
normalizedCompound: `${email.toLowerCase()}:${localPart}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("AddressRepo", () => {
|
|
21
|
+
let db: TestDb;
|
|
22
|
+
let close: () => Promise<void>;
|
|
23
|
+
let repo: AddressRepo;
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
({ db, close } = await createTestDb());
|
|
27
|
+
repo = new AddressRepo(db as never);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
after(async () => {
|
|
31
|
+
await close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("createAddress and getAddress", async () => {
|
|
35
|
+
const accountConfigId = randomId();
|
|
36
|
+
const input = makeAddressInput(accountConfigId);
|
|
37
|
+
const addr = await repo.createAddress(input);
|
|
38
|
+
|
|
39
|
+
assert.equal(addr.addressId, input.addressId);
|
|
40
|
+
assert.equal(addr.normalizedEmail, "test@example.com");
|
|
41
|
+
assert.deepEqual(addr.flags, {});
|
|
42
|
+
assert.equal(addr.inboundCount, 0);
|
|
43
|
+
|
|
44
|
+
const fetched = await repo.getAddress(accountConfigId, addr.addressId);
|
|
45
|
+
assert.equal(fetched.addressId, addr.addressId);
|
|
46
|
+
|
|
47
|
+
await repo.deleteAddress(accountConfigId, addr.addressId);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("upsertAddress is idempotent", async () => {
|
|
51
|
+
const input = makeAddressInput(randomId());
|
|
52
|
+
const first = await repo.upsertAddress(input);
|
|
53
|
+
const second = await repo.upsertAddress(input);
|
|
54
|
+
assert.equal(first.addressId, second.addressId);
|
|
55
|
+
|
|
56
|
+
await repo.deleteAddress(input.accountConfigId, input.addressId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("batchGet missing keys returns only found items", async () => {
|
|
60
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
61
|
+
const results = await repo.getAddress(addr.accountConfigId, [
|
|
62
|
+
addr.addressId,
|
|
63
|
+
randomId(),
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(results.length, 1);
|
|
66
|
+
assert.equal(results[0].addressId, addr.addressId);
|
|
67
|
+
|
|
68
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("batchGet empty array returns []", async () => {
|
|
72
|
+
const results = await repo.getAddress(randomId(), []);
|
|
73
|
+
assert.deepEqual(results, []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("mergeFlags updates flags", async () => {
|
|
77
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
78
|
+
const updated = await repo.mergeFlags(
|
|
79
|
+
addr.accountConfigId,
|
|
80
|
+
addr.addressId,
|
|
81
|
+
{
|
|
82
|
+
trusted: { value: true, setAt: Date.now() },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
assert.equal(updated.flags?.trusted?.value, true);
|
|
86
|
+
|
|
87
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("incrementInboundCount atomically increments", async () => {
|
|
91
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
await repo.incrementInboundCount(addr.accountConfigId, addr.addressId, now);
|
|
94
|
+
|
|
95
|
+
const updated = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
96
|
+
assert.equal(updated.inboundCount, 1);
|
|
97
|
+
|
|
98
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("createEnvelopeAddress and getEnvelopeAddress", async () => {
|
|
102
|
+
const messageId = randomUUID();
|
|
103
|
+
const addressId = randomUUID();
|
|
104
|
+
const envId = envelopeAddressId(messageId, "from", 0);
|
|
105
|
+
|
|
106
|
+
const addr = await repo.createAddress({
|
|
107
|
+
...makeAddressInput(randomId()),
|
|
108
|
+
addressId,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const ea = await repo.createEnvelopeAddress({
|
|
112
|
+
envelopeAddressId: envId,
|
|
113
|
+
messageId,
|
|
114
|
+
addressId,
|
|
115
|
+
normalizedEmail: "test@example.com",
|
|
116
|
+
addressRole: "from",
|
|
117
|
+
addressOrder: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(ea.envelopeAddressId, envId);
|
|
121
|
+
assert.equal(ea.messageId, messageId);
|
|
122
|
+
assert.equal(ea.addressRole, "from");
|
|
123
|
+
|
|
124
|
+
const fetched = await repo.getEnvelopeAddress(envId);
|
|
125
|
+
assert.equal(fetched.envelopeAddressId, envId);
|
|
126
|
+
|
|
127
|
+
await repo.deleteEnvelopeAddress(envId);
|
|
128
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("upsertEnvelopeAddress is idempotent", async () => {
|
|
132
|
+
const messageId = randomUUID();
|
|
133
|
+
const input = {
|
|
134
|
+
envelopeAddressId: envelopeAddressId(messageId, "to", 1),
|
|
135
|
+
messageId,
|
|
136
|
+
addressId: randomUUID(),
|
|
137
|
+
normalizedEmail: "x@example.com",
|
|
138
|
+
addressRole: "to" as const,
|
|
139
|
+
addressOrder: 1,
|
|
140
|
+
};
|
|
141
|
+
const first = await repo.upsertEnvelopeAddress(input);
|
|
142
|
+
const second = await repo.upsertEnvelopeAddress(input);
|
|
143
|
+
assert.equal(first.envelopeAddressId, second.envelopeAddressId);
|
|
144
|
+
|
|
145
|
+
await repo.deleteEnvelopeAddress(input.envelopeAddressId);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("deleteManyEnvelopeAddresses removes in batch", async () => {
|
|
149
|
+
const messageId = randomUUID();
|
|
150
|
+
const ea1 = {
|
|
151
|
+
envelopeAddressId: envelopeAddressId(messageId, "from", 0),
|
|
152
|
+
messageId,
|
|
153
|
+
addressId: randomUUID(),
|
|
154
|
+
normalizedEmail: "a@x.com",
|
|
155
|
+
addressRole: "from" as const,
|
|
156
|
+
addressOrder: 0,
|
|
157
|
+
};
|
|
158
|
+
const ea2 = {
|
|
159
|
+
...ea1,
|
|
160
|
+
envelopeAddressId: envelopeAddressId(messageId, "to", 1),
|
|
161
|
+
addressRole: "to" as const,
|
|
162
|
+
addressOrder: 1,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await repo.createEnvelopeAddress(ea1);
|
|
166
|
+
await repo.createEnvelopeAddress(ea2);
|
|
167
|
+
|
|
168
|
+
await repo.deleteManyEnvelopeAddresses([
|
|
169
|
+
ea1.envelopeAddressId,
|
|
170
|
+
ea2.envelopeAddressId,
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const results = await repo.getEnvelopeAddress([
|
|
174
|
+
ea1.envelopeAddressId,
|
|
175
|
+
ea2.envelopeAddressId,
|
|
176
|
+
]);
|
|
177
|
+
assert.equal(results.length, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("listByAccountConfig paginates without dupes, gaps, or non-termination", async () => {
|
|
181
|
+
const accountConfigId = randomId();
|
|
182
|
+
const created: string[] = [];
|
|
183
|
+
for (const c of ["a", "b", "c", "d", "e"]) {
|
|
184
|
+
const addr = await repo.createAddress(
|
|
185
|
+
makeAddressInput(accountConfigId, `${c}@x.com`),
|
|
186
|
+
);
|
|
187
|
+
created.push(addr.addressId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const seen: string[] = [];
|
|
191
|
+
let cursor: string | undefined;
|
|
192
|
+
let pages = 0;
|
|
193
|
+
do {
|
|
194
|
+
const page = await repo.listByAccountConfig({
|
|
195
|
+
accountConfigId,
|
|
196
|
+
limit: 2,
|
|
197
|
+
cursor,
|
|
198
|
+
});
|
|
199
|
+
seen.push(...page.items.map((a) => a.addressId));
|
|
200
|
+
cursor = page.continuationToken;
|
|
201
|
+
pages++;
|
|
202
|
+
assert.ok(pages < 10, "pagination must terminate");
|
|
203
|
+
} while (cursor);
|
|
204
|
+
|
|
205
|
+
assert.equal(seen.length, 5, "every row returned exactly once");
|
|
206
|
+
assert.equal(new Set(seen).size, 5, "no duplicates across pages");
|
|
207
|
+
assert.deepEqual([...seen].sort(), [...created].sort(), "no gaps");
|
|
208
|
+
|
|
209
|
+
await repo.deleteManyAddresses(accountConfigId, created);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("cross-tenant: getAddress refuses a foreign accountConfig", async () => {
|
|
213
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
214
|
+
const other = randomId();
|
|
215
|
+
|
|
216
|
+
await assert.rejects(
|
|
217
|
+
() => repo.getAddress(other, addr.addressId),
|
|
218
|
+
/Address not found/,
|
|
219
|
+
);
|
|
220
|
+
assert.deepEqual(await repo.getAddress(other, [addr.addressId]), []);
|
|
221
|
+
const owned = await repo.getAddress(addr.accountConfigId, [addr.addressId]);
|
|
222
|
+
assert.equal(owned.length, 1);
|
|
223
|
+
|
|
224
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("cross-tenant: updateAddress refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
228
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
229
|
+
const other = randomId();
|
|
230
|
+
|
|
231
|
+
await assert.rejects(
|
|
232
|
+
() => repo.updateAddress(other, addr.addressId, { displayName: "X" }),
|
|
233
|
+
/Address not found/,
|
|
234
|
+
);
|
|
235
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
236
|
+
assert.equal(still.displayName, undefined);
|
|
237
|
+
|
|
238
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("cross-tenant: mergeFlags refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
242
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
243
|
+
const other = randomId();
|
|
244
|
+
|
|
245
|
+
await assert.rejects(
|
|
246
|
+
() =>
|
|
247
|
+
repo.mergeFlags(other, addr.addressId, {
|
|
248
|
+
trusted: { value: true, setAt: Date.now() },
|
|
249
|
+
}),
|
|
250
|
+
/Address not found/,
|
|
251
|
+
);
|
|
252
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
253
|
+
assert.deepEqual(still.flags, {});
|
|
254
|
+
|
|
255
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("cross-tenant: promoteWellknownByUser refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
259
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
260
|
+
const other = randomId();
|
|
261
|
+
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => repo.promoteWellknownByUser(other, addr.addressId, Date.now()),
|
|
264
|
+
/Address not found/,
|
|
265
|
+
);
|
|
266
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
267
|
+
assert.equal(still.flags?.wellknown, undefined);
|
|
268
|
+
|
|
269
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("cross-tenant: demoteSenderTrust refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
273
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
274
|
+
await repo.incrementInboundCount(
|
|
275
|
+
addr.accountConfigId,
|
|
276
|
+
addr.addressId,
|
|
277
|
+
Date.now(),
|
|
278
|
+
);
|
|
279
|
+
const other = randomId();
|
|
280
|
+
|
|
281
|
+
await assert.rejects(
|
|
282
|
+
() => repo.demoteSenderTrust(other, addr.addressId, Date.now()),
|
|
283
|
+
/Address not found/,
|
|
284
|
+
);
|
|
285
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
286
|
+
assert.equal(still.inboundCount, 1);
|
|
287
|
+
|
|
288
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("cross-tenant: incrementInboundCount refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
292
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
293
|
+
const other = randomId();
|
|
294
|
+
|
|
295
|
+
await assert.rejects(
|
|
296
|
+
() => repo.incrementInboundCount(other, addr.addressId, Date.now()),
|
|
297
|
+
/Address not found/,
|
|
298
|
+
);
|
|
299
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
300
|
+
assert.equal(still.inboundCount, 0);
|
|
301
|
+
|
|
302
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("cross-tenant: incrementReplyCount refuses a foreign accountConfig and leaves the row unchanged", async () => {
|
|
306
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
307
|
+
const other = randomId();
|
|
308
|
+
|
|
309
|
+
await assert.rejects(
|
|
310
|
+
() => repo.incrementReplyCount(other, addr.addressId, Date.now()),
|
|
311
|
+
/Address not found/,
|
|
312
|
+
);
|
|
313
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
314
|
+
assert.equal(still.replyCount, 0);
|
|
315
|
+
|
|
316
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("cross-tenant: incrementOutboundCount is a no-op for a foreign accountConfig", async () => {
|
|
320
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
321
|
+
const other = randomId();
|
|
322
|
+
|
|
323
|
+
await repo.incrementOutboundCount(other, addr.addressId, Date.now());
|
|
324
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
325
|
+
assert.equal(still.outboundCount, 0);
|
|
326
|
+
|
|
327
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("cross-tenant: deleteAddress is a no-op for a foreign accountConfig", async () => {
|
|
331
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
332
|
+
const other = randomId();
|
|
333
|
+
|
|
334
|
+
await repo.deleteAddress(other, addr.addressId);
|
|
335
|
+
const still = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
336
|
+
assert.equal(still.addressId, addr.addressId);
|
|
337
|
+
|
|
338
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("cross-tenant: deleteManyAddresses only removes ids owned by the tenant", async () => {
|
|
342
|
+
const configA = randomId();
|
|
343
|
+
const configB = randomId();
|
|
344
|
+
const a = await repo.createAddress(makeAddressInput(configA, "a@x.com"));
|
|
345
|
+
const b = await repo.createAddress(makeAddressInput(configB, "b@x.com"));
|
|
346
|
+
|
|
347
|
+
await repo.deleteManyAddresses(configA, [a.addressId, b.addressId]);
|
|
348
|
+
|
|
349
|
+
await assert.rejects(
|
|
350
|
+
() => repo.getAddress(configA, a.addressId),
|
|
351
|
+
/Address not found/,
|
|
352
|
+
);
|
|
353
|
+
const survived = await repo.getAddress(configB, b.addressId);
|
|
354
|
+
assert.equal(survived.addressId, b.addressId);
|
|
355
|
+
|
|
356
|
+
await repo.deleteAddress(configB, b.addressId);
|
|
357
|
+
});
|
|
358
|
+
});
|