@crowdsource.you/core 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +571 -0
- package/dist/cases.d.ts +66 -0
- package/dist/cases.d.ts.map +1 -0
- package/dist/cases.js +79 -0
- package/dist/cases.js.map +1 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +125 -0
- package/dist/client.js.map +1 -0
- package/dist/communityNotes.d.ts +49 -0
- package/dist/communityNotes.d.ts.map +1 -0
- package/dist/communityNotes.js +104 -0
- package/dist/communityNotes.js.map +1 -0
- package/dist/credential.d.ts +58 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +71 -0
- package/dist/credential.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +82 -0
- package/dist/defaults.js.map +1 -0
- package/dist/digest.d.ts +44 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +79 -0
- package/dist/digest.js.map +1 -0
- package/dist/envelope.d.ts +143 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +258 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +90 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +151 -0
- package/dist/errors.js.map +1 -0
- package/dist/esm/cases.js +74 -0
- package/dist/esm/cases.js.map +1 -0
- package/dist/esm/client.js +121 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/communityNotes.js +100 -0
- package/dist/esm/communityNotes.js.map +1 -0
- package/dist/esm/credential.js +67 -0
- package/dist/esm/credential.js.map +1 -0
- package/dist/esm/defaults.js +77 -0
- package/dist/esm/defaults.js.map +1 -0
- package/dist/esm/digest.js +74 -0
- package/dist/esm/digest.js.map +1 -0
- package/dist/esm/envelope.js +252 -0
- package/dist/esm/envelope.js.map +1 -0
- package/dist/esm/errors.js +140 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/express/index.js +29 -0
- package/dist/esm/express/index.js.map +1 -0
- package/dist/esm/express/middleware.js +293 -0
- package/dist/esm/express/middleware.js.map +1 -0
- package/dist/esm/express/store.js +69 -0
- package/dist/esm/express/store.js.map +1 -0
- package/dist/esm/express/verify.js +133 -0
- package/dist/esm/express/verify.js.map +1 -0
- package/dist/esm/index.js +57 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/outbox/client.js +43 -0
- package/dist/esm/outbox/client.js.map +1 -0
- package/dist/esm/outbox/decision.js +153 -0
- package/dist/esm/outbox/decision.js.map +1 -0
- package/dist/esm/outbox/delivery.js +129 -0
- package/dist/esm/outbox/delivery.js.map +1 -0
- package/dist/esm/outbox/enforcement/executor.js +219 -0
- package/dist/esm/outbox/enforcement/executor.js.map +1 -0
- package/dist/esm/outbox/enforcement/planner.js +326 -0
- package/dist/esm/outbox/enforcement/planner.js.map +1 -0
- package/dist/esm/outbox/evidence.js +173 -0
- package/dist/esm/outbox/evidence.js.map +1 -0
- package/dist/esm/outbox/inbound.js +87 -0
- package/dist/esm/outbox/inbound.js.map +1 -0
- package/dist/esm/outbox/index.js +71 -0
- package/dist/esm/outbox/index.js.map +1 -0
- package/dist/esm/outbox/intake.js +140 -0
- package/dist/esm/outbox/intake.js.map +1 -0
- package/dist/esm/outbox/integration.js +116 -0
- package/dist/esm/outbox/integration.js.map +1 -0
- package/dist/esm/outbox/outbox/dispatcher.js +114 -0
- package/dist/esm/outbox/outbox/dispatcher.js.map +1 -0
- package/dist/esm/outbox/outbox/service.js +271 -0
- package/dist/esm/outbox/outbox/service.js.map +1 -0
- package/dist/esm/outbox/postgres/index.js +41 -0
- package/dist/esm/outbox/postgres/index.js.map +1 -0
- package/dist/esm/outbox/postgres/registries.js +155 -0
- package/dist/esm/outbox/postgres/registries.js.map +1 -0
- package/dist/esm/outbox/postgres/reportColumns.js +191 -0
- package/dist/esm/outbox/postgres/reportColumns.js.map +1 -0
- package/dist/esm/outbox/postgres/store/enforcement.js +158 -0
- package/dist/esm/outbox/postgres/store/enforcement.js.map +1 -0
- package/dist/esm/outbox/postgres/store/events.js +101 -0
- package/dist/esm/outbox/postgres/store/events.js.map +1 -0
- package/dist/esm/outbox/postgres/store/index.js +59 -0
- package/dist/esm/outbox/postgres/store/index.js.map +1 -0
- package/dist/esm/outbox/postgres/store/outbox.js +207 -0
- package/dist/esm/outbox/postgres/store/outbox.js.map +1 -0
- package/dist/esm/outbox/postgres/store/reports.js +231 -0
- package/dist/esm/outbox/postgres/store/reports.js.map +1 -0
- package/dist/esm/outbox/postgres/store/transaction.js +29 -0
- package/dist/esm/outbox/postgres/store/transaction.js.map +1 -0
- package/dist/esm/outbox/postgres/tables.js +270 -0
- package/dist/esm/outbox/postgres/tables.js.map +1 -0
- package/dist/esm/outbox/reconciliation.js +157 -0
- package/dist/esm/outbox/reconciliation.js.map +1 -0
- package/dist/esm/outbox/reportStatus.js +22 -0
- package/dist/esm/outbox/reportStatus.js.map +1 -0
- package/dist/esm/outbox/retention.js +33 -0
- package/dist/esm/outbox/retention.js.map +1 -0
- package/dist/esm/outbox/store/types.js +30 -0
- package/dist/esm/outbox/store/types.js.map +1 -0
- package/dist/esm/outbox/types.js +23 -0
- package/dist/esm/outbox/types.js.map +1 -0
- package/dist/esm/outbox/webhook.js +145 -0
- package/dist/esm/outbox/webhook.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/reports.js +74 -0
- package/dist/esm/reports.js.map +1 -0
- package/dist/esm/testing/fixtures.js +132 -0
- package/dist/esm/testing/fixtures.js.map +1 -0
- package/dist/esm/testing/index.js +33 -0
- package/dist/esm/testing/index.js.map +1 -0
- package/dist/esm/testing/sandbox.js +337 -0
- package/dist/esm/testing/sandbox.js.map +1 -0
- package/dist/esm/testing/webhook-simulator.js +93 -0
- package/dist/esm/testing/webhook-simulator.js.map +1 -0
- package/dist/esm/transport.js +177 -0
- package/dist/esm/transport.js.map +1 -0
- package/dist/esm/webhookEndpoints.js +139 -0
- package/dist/esm/webhookEndpoints.js.map +1 -0
- package/dist/express/index.d.ts +32 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +40 -0
- package/dist/express/index.js.map +1 -0
- package/dist/express/middleware.d.ts +97 -0
- package/dist/express/middleware.d.ts.map +1 -0
- package/dist/express/middleware.js +298 -0
- package/dist/express/middleware.js.map +1 -0
- package/dist/express/store.d.ts +45 -0
- package/dist/express/store.d.ts.map +1 -0
- package/dist/express/store.js +72 -0
- package/dist/express/store.js.map +1 -0
- package/dist/express/verify.d.ts +86 -0
- package/dist/express/verify.d.ts.map +1 -0
- package/dist/express/verify.js +138 -0
- package/dist/express/verify.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox/client.d.ts +35 -0
- package/dist/outbox/client.d.ts.map +1 -0
- package/dist/outbox/client.js +46 -0
- package/dist/outbox/client.js.map +1 -0
- package/dist/outbox/decision.d.ts +36 -0
- package/dist/outbox/decision.d.ts.map +1 -0
- package/dist/outbox/decision.js +159 -0
- package/dist/outbox/decision.js.map +1 -0
- package/dist/outbox/delivery.d.ts +50 -0
- package/dist/outbox/delivery.d.ts.map +1 -0
- package/dist/outbox/delivery.js +135 -0
- package/dist/outbox/delivery.js.map +1 -0
- package/dist/outbox/enforcement/executor.d.ts +26 -0
- package/dist/outbox/enforcement/executor.d.ts.map +1 -0
- package/dist/outbox/enforcement/executor.js +222 -0
- package/dist/outbox/enforcement/executor.js.map +1 -0
- package/dist/outbox/enforcement/planner.d.ts +50 -0
- package/dist/outbox/enforcement/planner.d.ts.map +1 -0
- package/dist/outbox/enforcement/planner.js +333 -0
- package/dist/outbox/enforcement/planner.js.map +1 -0
- package/dist/outbox/evidence.d.ts +114 -0
- package/dist/outbox/evidence.d.ts.map +1 -0
- package/dist/outbox/evidence.js +181 -0
- package/dist/outbox/evidence.js.map +1 -0
- package/dist/outbox/inbound.d.ts +78 -0
- package/dist/outbox/inbound.d.ts.map +1 -0
- package/dist/outbox/inbound.js +91 -0
- package/dist/outbox/inbound.js.map +1 -0
- package/dist/outbox/index.d.ts +79 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +98 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/intake.d.ts +62 -0
- package/dist/outbox/intake.d.ts.map +1 -0
- package/dist/outbox/intake.js +145 -0
- package/dist/outbox/intake.js.map +1 -0
- package/dist/outbox/integration.d.ts +62 -0
- package/dist/outbox/integration.d.ts.map +1 -0
- package/dist/outbox/integration.js +119 -0
- package/dist/outbox/integration.js.map +1 -0
- package/dist/outbox/outbox/dispatcher.d.ts +26 -0
- package/dist/outbox/outbox/dispatcher.d.ts.map +1 -0
- package/dist/outbox/outbox/dispatcher.js +119 -0
- package/dist/outbox/outbox/dispatcher.js.map +1 -0
- package/dist/outbox/outbox/service.d.ts +124 -0
- package/dist/outbox/outbox/service.d.ts.map +1 -0
- package/dist/outbox/outbox/service.js +279 -0
- package/dist/outbox/outbox/service.js.map +1 -0
- package/dist/outbox/postgres/index.d.ts +44 -0
- package/dist/outbox/postgres/index.d.ts.map +1 -0
- package/dist/outbox/postgres/index.js +51 -0
- package/dist/outbox/postgres/index.js.map +1 -0
- package/dist/outbox/postgres/registries.d.ts +69 -0
- package/dist/outbox/postgres/registries.d.ts.map +1 -0
- package/dist/outbox/postgres/registries.js +159 -0
- package/dist/outbox/postgres/registries.js.map +1 -0
- package/dist/outbox/postgres/reportColumns.d.ts +154 -0
- package/dist/outbox/postgres/reportColumns.d.ts.map +1 -0
- package/dist/outbox/postgres/reportColumns.js +195 -0
- package/dist/outbox/postgres/reportColumns.js.map +1 -0
- package/dist/outbox/postgres/store/enforcement.d.ts +33 -0
- package/dist/outbox/postgres/store/enforcement.d.ts.map +1 -0
- package/dist/outbox/postgres/store/enforcement.js +161 -0
- package/dist/outbox/postgres/store/enforcement.js.map +1 -0
- package/dist/outbox/postgres/store/events.d.ts +46 -0
- package/dist/outbox/postgres/store/events.d.ts.map +1 -0
- package/dist/outbox/postgres/store/events.js +104 -0
- package/dist/outbox/postgres/store/events.js.map +1 -0
- package/dist/outbox/postgres/store/index.d.ts +27 -0
- package/dist/outbox/postgres/store/index.d.ts.map +1 -0
- package/dist/outbox/postgres/store/index.js +62 -0
- package/dist/outbox/postgres/store/index.js.map +1 -0
- package/dist/outbox/postgres/store/outbox.d.ts +8 -0
- package/dist/outbox/postgres/store/outbox.d.ts.map +1 -0
- package/dist/outbox/postgres/store/outbox.js +210 -0
- package/dist/outbox/postgres/store/outbox.js.map +1 -0
- package/dist/outbox/postgres/store/reports.d.ts +69 -0
- package/dist/outbox/postgres/store/reports.d.ts.map +1 -0
- package/dist/outbox/postgres/store/reports.js +234 -0
- package/dist/outbox/postgres/store/reports.js.map +1 -0
- package/dist/outbox/postgres/store/transaction.d.ts +49 -0
- package/dist/outbox/postgres/store/transaction.d.ts.map +1 -0
- package/dist/outbox/postgres/store/transaction.js +32 -0
- package/dist/outbox/postgres/store/transaction.js.map +1 -0
- package/dist/outbox/postgres/tables.d.ts +737 -0
- package/dist/outbox/postgres/tables.d.ts.map +1 -0
- package/dist/outbox/postgres/tables.js +273 -0
- package/dist/outbox/postgres/tables.js.map +1 -0
- package/dist/outbox/reconciliation.d.ts +49 -0
- package/dist/outbox/reconciliation.d.ts.map +1 -0
- package/dist/outbox/reconciliation.js +162 -0
- package/dist/outbox/reconciliation.js.map +1 -0
- package/dist/outbox/reportStatus.d.ts +12 -0
- package/dist/outbox/reportStatus.d.ts.map +1 -0
- package/dist/outbox/reportStatus.js +25 -0
- package/dist/outbox/reportStatus.js.map +1 -0
- package/dist/outbox/retention.d.ts +33 -0
- package/dist/outbox/retention.d.ts.map +1 -0
- package/dist/outbox/retention.js +36 -0
- package/dist/outbox/retention.js.map +1 -0
- package/dist/outbox/store/types.d.ts +466 -0
- package/dist/outbox/store/types.d.ts.map +1 -0
- package/dist/outbox/store/types.js +31 -0
- package/dist/outbox/store/types.js.map +1 -0
- package/dist/outbox/types.d.ts +660 -0
- package/dist/outbox/types.d.ts.map +1 -0
- package/dist/outbox/types.js +24 -0
- package/dist/outbox/types.js.map +1 -0
- package/dist/outbox/webhook.d.ts +15 -0
- package/dist/outbox/webhook.d.ts.map +1 -0
- package/dist/outbox/webhook.js +148 -0
- package/dist/outbox/webhook.js.map +1 -0
- package/dist/reports.d.ts +73 -0
- package/dist/reports.d.ts.map +1 -0
- package/dist/reports.js +78 -0
- package/dist/reports.js.map +1 -0
- package/dist/testing/fixtures.d.ts +62 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +137 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +43 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/sandbox.d.ts +109 -0
- package/dist/testing/sandbox.d.ts.map +1 -0
- package/dist/testing/sandbox.js +342 -0
- package/dist/testing/sandbox.js.map +1 -0
- package/dist/testing/webhook-simulator.d.ts +78 -0
- package/dist/testing/webhook-simulator.d.ts.map +1 -0
- package/dist/testing/webhook-simulator.js +98 -0
- package/dist/testing/webhook-simulator.js.map +1 -0
- package/dist/transport.d.ts +59 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +181 -0
- package/dist/transport.js.map +1 -0
- package/dist/webhookEndpoints.d.ts +147 -0
- package/dist/webhookEndpoints.d.ts.map +1 -0
- package/dist/webhookEndpoints.js +143 -0
- package/dist/webhookEndpoints.js.map +1 -0
- package/package.json +127 -0
- package/src/cases.ts +119 -0
- package/src/client.ts +202 -0
- package/src/communityNotes.ts +194 -0
- package/src/credential.ts +100 -0
- package/src/defaults.ts +87 -0
- package/src/digest.ts +91 -0
- package/src/envelope.ts +432 -0
- package/src/errors.ts +165 -0
- package/src/express/index.ts +43 -0
- package/src/express/middleware.ts +387 -0
- package/src/express/store.ts +85 -0
- package/src/express/verify.ts +166 -0
- package/src/index.ts +107 -0
- package/src/outbox/client.ts +75 -0
- package/src/outbox/decision.ts +209 -0
- package/src/outbox/delivery.ts +163 -0
- package/src/outbox/enforcement/executor.ts +288 -0
- package/src/outbox/enforcement/planner.ts +377 -0
- package/src/outbox/evidence.ts +231 -0
- package/src/outbox/inbound.ts +140 -0
- package/src/outbox/index.ts +160 -0
- package/src/outbox/intake.ts +175 -0
- package/src/outbox/integration.ts +205 -0
- package/src/outbox/outbox/dispatcher.ts +131 -0
- package/src/outbox/outbox/service.ts +444 -0
- package/src/outbox/postgres/index.ts +59 -0
- package/src/outbox/postgres/registries.ts +177 -0
- package/src/outbox/postgres/reportColumns.ts +281 -0
- package/src/outbox/postgres/store/enforcement.ts +186 -0
- package/src/outbox/postgres/store/events.ts +112 -0
- package/src/outbox/postgres/store/index.ts +72 -0
- package/src/outbox/postgres/store/outbox.ts +273 -0
- package/src/outbox/postgres/store/reports.ts +349 -0
- package/src/outbox/postgres/store/transaction.ts +64 -0
- package/src/outbox/postgres/tables.ts +339 -0
- package/src/outbox/reconciliation.ts +199 -0
- package/src/outbox/reportStatus.ts +24 -0
- package/src/outbox/retention.ts +34 -0
- package/src/outbox/store/types.ts +528 -0
- package/src/outbox/types.ts +753 -0
- package/src/outbox/webhook.ts +165 -0
- package/src/reports.ts +138 -0
- package/src/testing/fixtures.ts +183 -0
- package/src/testing/index.ts +57 -0
- package/src/testing/sandbox.ts +469 -0
- package/src/testing/webhook-simulator.ts +166 -0
- package/src/transport.ts +250 -0
- package/src/webhookEndpoints.ts +256 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { and, asc, eq, gt, inArray, lte, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { PgTransaction } from 'drizzle-orm/pg-core';
|
|
3
|
+
import { ModerationOutboxTransactionError } from '../../outbox/service.js';
|
|
4
|
+
import type { ModerationOutboxStore } from '../../store/types.js';
|
|
5
|
+
import type { ModerationOutboxEvent } from '../../types.js';
|
|
6
|
+
import type { ModerationTables } from '../tables.js';
|
|
7
|
+
import type { ModerationPgHandle } from './transaction.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The moderation outbox, in Postgres.
|
|
11
|
+
*
|
|
12
|
+
* Same six operations as the Mongo store, same policy handed down from the
|
|
13
|
+
* service above it, four places where the mechanism differs — and each of those
|
|
14
|
+
* is where the correctness lives:
|
|
15
|
+
*
|
|
16
|
+
* 1. **The transaction guard is `instanceof PgTransaction`.** Mongo's equivalent
|
|
17
|
+
* asks a session whether a transaction is open; here the mistake is passing
|
|
18
|
+
* the POOL handle where the `tx` belongs, which runs on a different connection
|
|
19
|
+
* and commits independently. Same lost guarantee, different shape, and it
|
|
20
|
+
* type-checks perfectly because both are `ModerationPgHandle`.
|
|
21
|
+
* 2. **The insert-if-absent is `ON CONFLICT DO NOTHING`.** A no-op by
|
|
22
|
+
* construction rather than by suppressing an ORM's timestamp behaviour: it
|
|
23
|
+
* writes nothing and takes no row lock on an already-committed conflicting
|
|
24
|
+
* row. Never `DO UPDATE` — that reintroduces exactly the defect the Mongo
|
|
25
|
+
* side's `timestamps: false` exists to prevent.
|
|
26
|
+
* 3. **The claim is `FOR UPDATE SKIP LOCKED`.** Load-bearing, not tuning: see
|
|
27
|
+
* `claim`.
|
|
28
|
+
* 4. **Every lease transition asks `RETURNING` how many rows matched.**
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The columns a claimed event is read back through. */
|
|
32
|
+
type ClaimedRow = {
|
|
33
|
+
id: string;
|
|
34
|
+
kind: ModerationOutboxEvent['kind'];
|
|
35
|
+
payload: ModerationOutboxEvent['payload'];
|
|
36
|
+
attempts: number;
|
|
37
|
+
availableAt: Date;
|
|
38
|
+
leaseOwner: string | null;
|
|
39
|
+
leaseUntil: Date | null;
|
|
40
|
+
expiresAt: Date;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function postgresOutboxStore(input: {
|
|
45
|
+
db: ModerationPgHandle;
|
|
46
|
+
tables: ModerationTables;
|
|
47
|
+
}): ModerationOutboxStore<ModerationPgHandle> {
|
|
48
|
+
const { db } = input;
|
|
49
|
+
const outbox = input.tables.outbox;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Due work, as one predicate.
|
|
53
|
+
*
|
|
54
|
+
* Either `pending` and past its `available_at`, or `processing` with an EXPIRED
|
|
55
|
+
* lease — the second arm is what makes a dead worker's event reclaimable rather
|
|
56
|
+
* than stranded. The two arms have an index each
|
|
57
|
+
* (`moderation_outbox_due_idx`, `moderation_outbox_lease_idx`).
|
|
58
|
+
*/
|
|
59
|
+
const dueOrExpired = (now: Date) =>
|
|
60
|
+
or(
|
|
61
|
+
and(eq(outbox.status, 'pending'), lte(outbox.availableAt, now)),
|
|
62
|
+
and(eq(outbox.status, 'processing'), lte(outbox.leaseUntil, now)),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** The lease this caller claims to hold, still live. Shared by all three transitions. */
|
|
66
|
+
const heldLease = (eventId: string, leaseOwner: string, now: Date) =>
|
|
67
|
+
and(
|
|
68
|
+
eq(outbox.id, eventId),
|
|
69
|
+
eq(outbox.status, 'processing'),
|
|
70
|
+
eq(outbox.leaseOwner, leaseOwner),
|
|
71
|
+
gt(outbox.leaseUntil, now),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const toEvent = (row: ClaimedRow): ModerationOutboxEvent => ({
|
|
75
|
+
id: row.id,
|
|
76
|
+
kind: row.kind,
|
|
77
|
+
payload: row.payload,
|
|
78
|
+
attempts: row.attempts,
|
|
79
|
+
availableAt: row.availableAt,
|
|
80
|
+
// `null` is how Postgres stores "no lease"; the event type says absent.
|
|
81
|
+
...(row.leaseOwner === null ? {} : { leaseOwner: row.leaseOwner }),
|
|
82
|
+
...(row.leaseUntil === null ? {} : { leaseUntil: row.leaseUntil }),
|
|
83
|
+
expiresAt: row.expiresAt,
|
|
84
|
+
createdAt: row.createdAt,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async enqueue(event, tx) {
|
|
89
|
+
/**
|
|
90
|
+
* The guard, and it is not the same mistake Mongo's guards against.
|
|
91
|
+
*
|
|
92
|
+
* Both `db` and `tx` are a `ModerationPgHandle`, so handing this the POOL
|
|
93
|
+
* handle type-checks perfectly — and then the row commits on its own
|
|
94
|
+
* connection, independently of the domain write it was supposed to be
|
|
95
|
+
* atomic with. That is "the report was answered 201 and never delivered",
|
|
96
|
+
* reached by a different route than a session nobody opened a transaction
|
|
97
|
+
* on, with the same silence.
|
|
98
|
+
*
|
|
99
|
+
* `PgTransaction` is a real runtime class in `drizzle-orm/pg-core`, so this
|
|
100
|
+
* is a genuine check rather than a duck-typed guess.
|
|
101
|
+
*/
|
|
102
|
+
if (!(tx instanceof PgTransaction)) {
|
|
103
|
+
throw new ModerationOutboxTransactionError(event.eventId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `ON CONFLICT DO NOTHING`, never `DO UPDATE`.
|
|
108
|
+
*
|
|
109
|
+
* A repeated enqueue is ORDINARY — a transaction retry, two concurrent
|
|
110
|
+
* duplicate submissions, a reconciliation sweep re-deriving an event — and
|
|
111
|
+
* the dispatcher is concurrently taking, renewing and completing leases on
|
|
112
|
+
* these same rows. `DO UPDATE` would make each repeat a real write, which
|
|
113
|
+
* conflicts with a live lease update and aborts the enclosing transaction:
|
|
114
|
+
* the exact defect the Mongo store's `timestamps: false` exists to prevent,
|
|
115
|
+
* reintroduced in a dialect where nothing forces it on you.
|
|
116
|
+
*
|
|
117
|
+
* One behavioural difference from Mongo, and Postgres has the better end of
|
|
118
|
+
* it: if a CONCURRENT UNCOMMITTED transaction holds this same key, Postgres
|
|
119
|
+
* WAITS for it and then proceeds (finding the row committed, and doing
|
|
120
|
+
* nothing), where Mongo raises `WriteConflict` (code 112) and aborts the
|
|
121
|
+
* enclosing transaction. Waiting is the outcome a caller wants.
|
|
122
|
+
*
|
|
123
|
+
* `created_at` and `updated_at` are written explicitly from the caller's
|
|
124
|
+
* clock rather than left to their defaults, so both backends stamp a row
|
|
125
|
+
* from one instant — and so a test can assert that a repeat changed
|
|
126
|
+
* NOTHING, which is a stronger claim than "no duplicate row".
|
|
127
|
+
*/
|
|
128
|
+
await tx
|
|
129
|
+
.insert(outbox)
|
|
130
|
+
.values({
|
|
131
|
+
id: event.eventId,
|
|
132
|
+
kind: event.kind,
|
|
133
|
+
payload: event.payload,
|
|
134
|
+
status: 'pending',
|
|
135
|
+
attempts: 0,
|
|
136
|
+
availableAt: event.availableAt,
|
|
137
|
+
expiresAt: event.expiresAt,
|
|
138
|
+
createdAt: event.now,
|
|
139
|
+
updatedAt: event.now,
|
|
140
|
+
})
|
|
141
|
+
.onConflictDoNothing({ target: outbox.id });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async claim({ leaseOwner, leaseUntil, now, eventId }) {
|
|
145
|
+
/**
|
|
146
|
+
* `SKIP LOCKED` is load-bearing, not tuning.
|
|
147
|
+
*
|
|
148
|
+
* Without it, under READ COMMITTED the sub-select is evaluated once: the
|
|
149
|
+
* loser blocks on the head row and then returns ZERO rows. `claim` answers
|
|
150
|
+
* `null`, `dispatch` breaks out of its batch, and a deployment running N
|
|
151
|
+
* tasks silently drains at 1/N the rate with nothing failing anywhere.
|
|
152
|
+
*
|
|
153
|
+
* The `FOR UPDATE` lives in the SUB-SELECT because that is what locks the
|
|
154
|
+
* one row this claim intends to take, before the UPDATE touches it — the
|
|
155
|
+
* documented Postgres idiom for a work queue.
|
|
156
|
+
*/
|
|
157
|
+
const due = db
|
|
158
|
+
.select({ id: outbox.id })
|
|
159
|
+
.from(outbox)
|
|
160
|
+
.where(
|
|
161
|
+
eventId === undefined
|
|
162
|
+
? dueOrExpired(now)
|
|
163
|
+
: and(eq(outbox.id, eventId), dueOrExpired(now)),
|
|
164
|
+
)
|
|
165
|
+
// Oldest first, so a backlog drains in the order it was filed rather than
|
|
166
|
+
// in whatever order the storage engine finds convenient.
|
|
167
|
+
.orderBy(asc(outbox.createdAt))
|
|
168
|
+
.limit(1)
|
|
169
|
+
.for('update', { skipLocked: true });
|
|
170
|
+
|
|
171
|
+
const rows = await db
|
|
172
|
+
.update(outbox)
|
|
173
|
+
.set({
|
|
174
|
+
status: 'processing',
|
|
175
|
+
leaseOwner,
|
|
176
|
+
leaseUntil,
|
|
177
|
+
// The COLUMN is interpolated, not a value: `attempts` is incremented by
|
|
178
|
+
// the database so two claimers cannot both read 3 and both write 4.
|
|
179
|
+
attempts: sql`${outbox.attempts} + 1`,
|
|
180
|
+
// The previous attempt's error, cleared with the claim so a stale
|
|
181
|
+
// message can never be read as this attempt's. `null` CLEARS in
|
|
182
|
+
// drizzle; `undefined` would leave it alone.
|
|
183
|
+
lastError: null,
|
|
184
|
+
updatedAt: now,
|
|
185
|
+
})
|
|
186
|
+
.where(inArray(outbox.id, due))
|
|
187
|
+
.returning({
|
|
188
|
+
id: outbox.id,
|
|
189
|
+
kind: outbox.kind,
|
|
190
|
+
payload: outbox.payload,
|
|
191
|
+
attempts: outbox.attempts,
|
|
192
|
+
availableAt: outbox.availableAt,
|
|
193
|
+
leaseOwner: outbox.leaseOwner,
|
|
194
|
+
leaseUntil: outbox.leaseUntil,
|
|
195
|
+
expiresAt: outbox.expiresAt,
|
|
196
|
+
createdAt: outbox.createdAt,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const [row] = rows;
|
|
200
|
+
return row === undefined ? null : toEvent(row);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* ## Why all three transitions read `RETURNING`, and what that collapses
|
|
205
|
+
*
|
|
206
|
+
* Mongo's `complete` and `fail` answer `modifiedCount === 1` while its
|
|
207
|
+
* `renew` answers `matchedCount === 1`. `RETURNING` counts MATCHED rows, so
|
|
208
|
+
* this store answers the `matchedCount` question in all three places.
|
|
209
|
+
*
|
|
210
|
+
* That is equivalent HERE, and the argument is worth writing down because it
|
|
211
|
+
* is an argument rather than a test: the WHERE clause requires
|
|
212
|
+
* `status = 'processing'`, and `complete` and `fail` both write a different
|
|
213
|
+
* status, so a matched row is always a modified row. `renew` writes only
|
|
214
|
+
* `lease_until`/`updated_at` and Mongo already used `matchedCount` for it —
|
|
215
|
+
* a renewal that lands on the values already stored is still a lease this
|
|
216
|
+
* caller holds, and reporting it as lost would make a dispatcher abandon an
|
|
217
|
+
* event it still owns.
|
|
218
|
+
*
|
|
219
|
+
* If a later transition ever stops changing `status`, this equivalence stops
|
|
220
|
+
* holding and the difference becomes silent.
|
|
221
|
+
*/
|
|
222
|
+
async complete({ eventId, leaseOwner, now }) {
|
|
223
|
+
const rows = await db
|
|
224
|
+
.update(outbox)
|
|
225
|
+
.set({
|
|
226
|
+
status: 'processed',
|
|
227
|
+
processedAt: now,
|
|
228
|
+
updatedAt: now,
|
|
229
|
+
leaseOwner: null,
|
|
230
|
+
leaseUntil: null,
|
|
231
|
+
lastError: null,
|
|
232
|
+
})
|
|
233
|
+
.where(heldLease(eventId, leaseOwner, now))
|
|
234
|
+
.returning({ id: outbox.id });
|
|
235
|
+
return rows.length === 1;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
async renew({ eventId, leaseOwner, leaseUntil, now }) {
|
|
239
|
+
const rows = await db
|
|
240
|
+
.update(outbox)
|
|
241
|
+
.set({ leaseUntil, updatedAt: now })
|
|
242
|
+
.where(heldLease(eventId, leaseOwner, now))
|
|
243
|
+
.returning({ id: outbox.id });
|
|
244
|
+
return rows.length === 1;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async fail({ eventId, leaseOwner, status, availableAt, lastError, now }) {
|
|
248
|
+
const rows = await db
|
|
249
|
+
.update(outbox)
|
|
250
|
+
.set({
|
|
251
|
+
status,
|
|
252
|
+
availableAt,
|
|
253
|
+
lastError,
|
|
254
|
+
updatedAt: now,
|
|
255
|
+
leaseOwner: null,
|
|
256
|
+
leaseUntil: null,
|
|
257
|
+
})
|
|
258
|
+
.where(heldLease(eventId, leaseOwner, now))
|
|
259
|
+
.returning({ id: outbox.id });
|
|
260
|
+
return rows.length === 1;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async statusOf(eventId) {
|
|
264
|
+
const rows = await db
|
|
265
|
+
.select({ status: outbox.status })
|
|
266
|
+
.from(outbox)
|
|
267
|
+
.where(eq(outbox.id, eventId))
|
|
268
|
+
.limit(1);
|
|
269
|
+
const [row] = rows;
|
|
270
|
+
return row === undefined ? null : row.status;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
|
|
2
|
+
import type {
|
|
3
|
+
ModerationReportDecisionUpdate,
|
|
4
|
+
ModerationReportRef,
|
|
5
|
+
ModerationReportStore,
|
|
6
|
+
} from '../../store/types.js';
|
|
7
|
+
import type { ModerationReportFields } from '../../types.js';
|
|
8
|
+
import type { ModerationReportTable } from '../reportColumns.js';
|
|
9
|
+
import type { ModerationPgHandle } from './transaction.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The application's own report table, in Postgres.
|
|
13
|
+
*
|
|
14
|
+
* The only store that takes a table it did not define, which is what makes the
|
|
15
|
+
* structural type worth having: an adopter who forgets a column this package
|
|
16
|
+
* queries gets a COMPILE ERROR at the `postgresModerationStore(...)` call rather
|
|
17
|
+
* than a runtime failure on the first delivery.
|
|
18
|
+
*
|
|
19
|
+
* `scripts/test-report-table-type.mjs` proves that gate in both directions and
|
|
20
|
+
* records exactly what it does not catch — a column present under the right name
|
|
21
|
+
* but the wrong TYPE passes, because every member of `ModerationReportTable` is a
|
|
22
|
+
* bare `PgColumn`. The DDL and the schema tests are what cover that half.
|
|
23
|
+
*
|
|
24
|
+
* ## Two Mongo hazards that do not exist here
|
|
25
|
+
*
|
|
26
|
+
* **A malformed id is not an error.** `id` is `text`, so an id nothing could have
|
|
27
|
+
* generated simply matches no rows and `findById` answers `null` — which is
|
|
28
|
+
* exactly what the delivery worker already does with "the report is gone".
|
|
29
|
+
* Mongoose raises a `CastError` for the same input and its store has to catch it.
|
|
30
|
+
* There is nothing to catch here, and adding a branch for `22P02` would be
|
|
31
|
+
* writing a handler for an error this column cannot raise.
|
|
32
|
+
*
|
|
33
|
+
* **A bound parameter cannot become a query operator.** `requireIdentifier` in
|
|
34
|
+
* `intake.ts` still runs, and still should — a non-string corrupts data on any
|
|
35
|
+
* backend, and the function is exported for callers with no route validation. But
|
|
36
|
+
* the specific failure it was written for, `{ $ne: null }` arriving as a value and
|
|
37
|
+
* matching an unrelated report, is a Mongo shape: here a parameter is a parameter.
|
|
38
|
+
*
|
|
39
|
+
* ## One thing that is worse, stated plainly
|
|
40
|
+
*
|
|
41
|
+
* Every read hands the caller `TReport`, and no type can prove that: the adopter
|
|
42
|
+
* owns the type AND the table, and the two are only connected by their names. See
|
|
43
|
+
* {@link asReport}.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The fields `ModerationReportFields` declares OPTIONAL, computed from the type.
|
|
48
|
+
*
|
|
49
|
+
* Postgres stores an absent value as NULL; Mongo omits the field. Both are the
|
|
50
|
+
* same claim — "this never happened" — and one suite has to be able to assert it
|
|
51
|
+
* once, so the package's own optional fields come back ABSENT from either
|
|
52
|
+
* backend. Two `describe.each` pairs failed on exactly this before it was
|
|
53
|
+
* settled, both reading `expected null to be undefined`, and neither was about
|
|
54
|
+
* behaviour.
|
|
55
|
+
*
|
|
56
|
+
* Only the fields the PORT owns are normalised. An adopter's own nullable column
|
|
57
|
+
* is theirs: `extra` goes in untouched and comes back untouched.
|
|
58
|
+
*/
|
|
59
|
+
type OptionalReportField = {
|
|
60
|
+
[K in keyof ModerationReportFields]-?: undefined extends ModerationReportFields[K]
|
|
61
|
+
? K
|
|
62
|
+
: never;
|
|
63
|
+
}[keyof ModerationReportFields];
|
|
64
|
+
|
|
65
|
+
const OPTIONAL_REPORT_FIELDS = [
|
|
66
|
+
'details',
|
|
67
|
+
'localStatusReason',
|
|
68
|
+
'crowdSourceReportId',
|
|
69
|
+
'crowdSourceCaseId',
|
|
70
|
+
'crowdSourceMerged',
|
|
71
|
+
'contentSnapshotHash',
|
|
72
|
+
'submittedAt',
|
|
73
|
+
'lastDeliveryError',
|
|
74
|
+
'decisionId',
|
|
75
|
+
'decisionRevision',
|
|
76
|
+
'decisionOutcome',
|
|
77
|
+
'decisionStatus',
|
|
78
|
+
'decidedAt',
|
|
79
|
+
'enforcedAction',
|
|
80
|
+
'enforcedAt',
|
|
81
|
+
] as const satisfies readonly OptionalReportField[];
|
|
82
|
+
|
|
83
|
+
/** `T` when it is `never`, and a compile error naming the field when it is not. */
|
|
84
|
+
type AssertNever<T extends never> = T;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The exhaustiveness gate. `satisfies` above refuses a field that is not optional;
|
|
88
|
+
* THIS refuses an optional field that is missing from the list — the direction
|
|
89
|
+
* that would otherwise ship as a backend difference nobody looked for.
|
|
90
|
+
*/
|
|
91
|
+
export type UncoveredOptionalReportField = AssertNever<
|
|
92
|
+
Exclude<OptionalReportField, (typeof OPTIONAL_REPORT_FIELDS)[number]>
|
|
93
|
+
>;
|
|
94
|
+
|
|
95
|
+
/** Drop the package's own optional fields when the column is NULL. */
|
|
96
|
+
function absentWhereNull(row: Record<string, unknown>): Record<string, unknown> {
|
|
97
|
+
const normalised: Record<string, unknown> = { ...row };
|
|
98
|
+
for (const field of OPTIONAL_REPORT_FIELDS) {
|
|
99
|
+
if (normalised[field] === null) delete normalised[field];
|
|
100
|
+
}
|
|
101
|
+
return normalised;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The row, as the port declares it.
|
|
106
|
+
*
|
|
107
|
+
* An unchecked declaration, not a conversion — the same escape the Mongoose store
|
|
108
|
+
* takes with `.lean<TReport>()`, and for the same reason: a driver cannot know an
|
|
109
|
+
* adopter's row type, and `ModerationReportTable` deliberately erases column types
|
|
110
|
+
* so that any adopter's table is accepted.
|
|
111
|
+
*
|
|
112
|
+
* What bounds it is that both halves are checked elsewhere. Column PRESENCE is a
|
|
113
|
+
* compile error, gated by `scripts/test-report-table-type.mjs`. Column VALUES are
|
|
114
|
+
* asserted by round-trips against a real database in
|
|
115
|
+
* `postgresReportStore.test.ts`. What remains unchecked is a column declared with
|
|
116
|
+
* the wrong TYPE, which the migration and the schema test cover instead.
|
|
117
|
+
*/
|
|
118
|
+
function asReport<TReport extends ModerationReportFields>(
|
|
119
|
+
row: Record<string, unknown>,
|
|
120
|
+
): TReport {
|
|
121
|
+
return absentWhereNull(row) as TReport;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function postgresReportStore<TReport extends ModerationReportFields>(input: {
|
|
125
|
+
db: ModerationPgHandle;
|
|
126
|
+
reportTable: ModerationReportTable;
|
|
127
|
+
}): ModerationReportStore<TReport, ModerationPgHandle> {
|
|
128
|
+
const { db } = input;
|
|
129
|
+
const reports = input.reportTable;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `count(*)::int`, and the cast is load-bearing.
|
|
133
|
+
*
|
|
134
|
+
* `count(*)` is `bigint`, which postgres.js hands back as a STRING to avoid
|
|
135
|
+
* losing precision — so `sql<number>` without the cast is an assertion that
|
|
136
|
+
* quietly lies, and the caller's `number` is `'3'`. The `::int` makes the
|
|
137
|
+
* database do the conversion.
|
|
138
|
+
*/
|
|
139
|
+
const total = sql<number>`count(*)::int`;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
async findDuplicate({ reporter, reportedId, reportedType }, tx) {
|
|
143
|
+
const rows = await tx
|
|
144
|
+
.select()
|
|
145
|
+
.from(reports)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(reports.reporter, reporter),
|
|
149
|
+
eq(reports.reportedId, reportedId),
|
|
150
|
+
eq(reports.reportedType, reportedType),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
.limit(1);
|
|
154
|
+
const [row] = rows;
|
|
155
|
+
return row === undefined ? null : asReport<TReport>(row);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async insert(report, tx) {
|
|
159
|
+
const rows = await tx
|
|
160
|
+
.insert(reports)
|
|
161
|
+
.values({
|
|
162
|
+
/**
|
|
163
|
+
* The application's own columns FIRST, so a field this package owns can
|
|
164
|
+
* never be overwritten by one it knows nothing about. A report whose
|
|
165
|
+
* `localStatus` came from `extra` would be queued with nothing to
|
|
166
|
+
* deliver it, or received with a delivery event that tries anyway.
|
|
167
|
+
*
|
|
168
|
+
* A key that is not a column raises here — Postgres has no silent
|
|
169
|
+
* discard. Mongoose strict mode drops an undeclared path with no throw
|
|
170
|
+
* and no warning, which is why the Mongo half needs a standing test
|
|
171
|
+
* that every DTO field resolves to a schema path.
|
|
172
|
+
*/
|
|
173
|
+
...report.extra,
|
|
174
|
+
reportedType: report.reportedType,
|
|
175
|
+
reportedId: report.reportedId,
|
|
176
|
+
reporter: report.reporter,
|
|
177
|
+
categories: [...report.categories],
|
|
178
|
+
...(report.details === undefined ? {} : { details: report.details }),
|
|
179
|
+
localStatus: report.localStatus,
|
|
180
|
+
...(report.localStatusReason === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { localStatusReason: report.localStatusReason }),
|
|
183
|
+
})
|
|
184
|
+
.returning();
|
|
185
|
+
|
|
186
|
+
const [row] = rows;
|
|
187
|
+
if (row === undefined) {
|
|
188
|
+
throw new Error('The moderation report insert returned no row.');
|
|
189
|
+
}
|
|
190
|
+
return asReport<TReport>(row);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async findById(reportId) {
|
|
194
|
+
const rows = await db.select().from(reports).where(eq(reports.id, reportId)).limit(1);
|
|
195
|
+
const [row] = rows;
|
|
196
|
+
return row === undefined ? null : asReport<TReport>(row);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async findByCaseId(caseId) {
|
|
200
|
+
const rows = await db
|
|
201
|
+
.select({
|
|
202
|
+
id: reports.id,
|
|
203
|
+
reportedType: reports.reportedType,
|
|
204
|
+
reportedId: reports.reportedId,
|
|
205
|
+
})
|
|
206
|
+
.from(reports)
|
|
207
|
+
.where(eq(reports.crowdSourceCaseId, caseId));
|
|
208
|
+
|
|
209
|
+
return rows.map(
|
|
210
|
+
(row): ModerationReportRef => ({
|
|
211
|
+
id: String(row.id),
|
|
212
|
+
reportedType: String(row.reportedType),
|
|
213
|
+
reportedId: String(row.reportedId),
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async applyDecision(reportId, update, maxRevision) {
|
|
219
|
+
const rows = await db
|
|
220
|
+
.update(reports)
|
|
221
|
+
.set(decisionSet(update))
|
|
222
|
+
.where(
|
|
223
|
+
and(
|
|
224
|
+
eq(reports.id, reportId),
|
|
225
|
+
/**
|
|
226
|
+
* The revision guard, in the WHERE clause. It is the DATABASE that
|
|
227
|
+
* refuses a stale write rather than a read-then-write in this
|
|
228
|
+
* process: deliveries overlap — CrowdSource retries for 24 hours, and
|
|
229
|
+
* a correction can arrive while the decision it supersedes is still
|
|
230
|
+
* being applied — and an older revision landing last would otherwise
|
|
231
|
+
* overwrite the current answer.
|
|
232
|
+
*
|
|
233
|
+
* `IS NULL` is the port of Mongo's `$exists: false`, because a report
|
|
234
|
+
* with no decision yet stores NULL rather than omitting the column.
|
|
235
|
+
* `<=` rather than `<` is deliberate: a redelivery of the SAME
|
|
236
|
+
* revision rewrites, which is harmless and keeps a partially-applied
|
|
237
|
+
* decision converging.
|
|
238
|
+
*/
|
|
239
|
+
or(
|
|
240
|
+
isNull(reports.decisionRevision),
|
|
241
|
+
lte(reports.decisionRevision, maxRevision),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
.returning({ id: reports.id });
|
|
246
|
+
|
|
247
|
+
return rows.length === 1;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async markSubmitted(reportId, submission) {
|
|
251
|
+
await db
|
|
252
|
+
.update(reports)
|
|
253
|
+
.set({
|
|
254
|
+
localStatus: 'submitted',
|
|
255
|
+
crowdSourceReportId: submission.crowdSourceReportId,
|
|
256
|
+
crowdSourceCaseId: submission.crowdSourceCaseId,
|
|
257
|
+
crowdSourceMerged: submission.crowdSourceMerged,
|
|
258
|
+
contentSnapshotHash: submission.contentSnapshotHash,
|
|
259
|
+
submittedAt: submission.submittedAt,
|
|
260
|
+
// A report that has landed carries no failure and no reason it was
|
|
261
|
+
// going nowhere. `null` CLEARS in drizzle; `undefined` would leave the
|
|
262
|
+
// stale value in place, which is the port of Mongo's `$unset`.
|
|
263
|
+
lastDeliveryError: null,
|
|
264
|
+
localStatusReason: null,
|
|
265
|
+
})
|
|
266
|
+
.where(eq(reports.id, reportId));
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async markDeliveryFailed(reportId, lastDeliveryError) {
|
|
270
|
+
await db
|
|
271
|
+
.update(reports)
|
|
272
|
+
.set({ localStatus: 'delivery_failed', lastDeliveryError })
|
|
273
|
+
.where(eq(reports.id, reportId));
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async close(reportId, localStatusReason) {
|
|
277
|
+
await db
|
|
278
|
+
.update(reports)
|
|
279
|
+
.set({ localStatus: 'closed', localStatusReason })
|
|
280
|
+
.where(eq(reports.id, reportId));
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async findPendingOldestFirst(limit) {
|
|
284
|
+
/**
|
|
285
|
+
* `queued` and `delivery_failed` only. `received` is excluded deliberately
|
|
286
|
+
* and the omission is the safety property, not an oversight: those reports
|
|
287
|
+
* have no subject provider, so an event re-derived for one would fail on
|
|
288
|
+
* its first attempt and dead-letter.
|
|
289
|
+
*
|
|
290
|
+
* Oldest first, and ASC needs no explicit NULLS placement: both drizzle and
|
|
291
|
+
* Postgres default ascending to NULLS LAST, so this matches the
|
|
292
|
+
* `(local_status, created_at)` index. A DESCENDING order would need the
|
|
293
|
+
* placement spelled out — see the enforcement store's reversal lookup for
|
|
294
|
+
* what that costs when it is not.
|
|
295
|
+
*/
|
|
296
|
+
const rows = await db
|
|
297
|
+
.select({ id: reports.id })
|
|
298
|
+
.from(reports)
|
|
299
|
+
.where(inArray(reports.localStatus, ['queued', 'delivery_failed']))
|
|
300
|
+
.orderBy(asc(reports.createdAt))
|
|
301
|
+
.limit(limit);
|
|
302
|
+
|
|
303
|
+
return rows.map((row) => String(row.id));
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
async countAwaitingDecision(submittedBefore) {
|
|
307
|
+
const rows = await db
|
|
308
|
+
.select({ total })
|
|
309
|
+
.from(reports)
|
|
310
|
+
.where(
|
|
311
|
+
and(
|
|
312
|
+
eq(reports.localStatus, 'submitted'),
|
|
313
|
+
lt(reports.submittedAt, submittedBefore),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
return rows[0]?.total ?? 0;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
async countLocalOnly() {
|
|
320
|
+
const rows = await db
|
|
321
|
+
.select({ total })
|
|
322
|
+
.from(reports)
|
|
323
|
+
.where(eq(reports.localStatus, 'received'));
|
|
324
|
+
return rows[0]?.total ?? 0;
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The `$set` a decision writes, as one object.
|
|
331
|
+
*
|
|
332
|
+
* `extra` FIRST, for the same reason it is first on insert: it is the adopter's
|
|
333
|
+
* legacy verdict field, and this package's own fields must win.
|
|
334
|
+
*/
|
|
335
|
+
function decisionSet(update: ModerationReportDecisionUpdate): Record<string, unknown> {
|
|
336
|
+
return {
|
|
337
|
+
...update.extra,
|
|
338
|
+
localStatus: update.localStatus,
|
|
339
|
+
decisionId: update.decisionId,
|
|
340
|
+
decisionRevision: update.decisionRevision,
|
|
341
|
+
decisionOutcome: update.decisionOutcome,
|
|
342
|
+
decisionStatus: update.decisionStatus,
|
|
343
|
+
decidedAt: update.decidedAt,
|
|
344
|
+
...(update.enforcedAction === undefined
|
|
345
|
+
? {}
|
|
346
|
+
: { enforcedAction: update.enforcedAction }),
|
|
347
|
+
...(update.enforcedAt === undefined ? {} : { enforcedAt: update.enforcedAt }),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { TablesRelationalConfig } from 'drizzle-orm';
|
|
2
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
3
|
+
import type { ModerationTransactionRunner } from '../../store/types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The drizzle handle this package's Postgres stores talk to.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately the BASE `PgDatabase` rather than a `PostgresJsDatabase`: the
|
|
9
|
+
* stores must accept both the pool handle an adopter builds and the `tx` handed
|
|
10
|
+
* to a `db.transaction` callback, and those are different types. Written this
|
|
11
|
+
* way, one signature serves both and no store needs to know which it holds.
|
|
12
|
+
*
|
|
13
|
+
* ## The generic that has to be `Record<string, unknown>`, and the error you get
|
|
14
|
+
*
|
|
15
|
+
* `TablesRelationalConfig` and `Record<string, unknown>` are the only spellings
|
|
16
|
+
* that accept both. The narrow one a reader reaches for first —
|
|
17
|
+
* `Record<string, never>` for the schema — accepts NEITHER (`TS2345`, the schema
|
|
18
|
+
* generic is invariant).
|
|
19
|
+
*
|
|
20
|
+
* The consequence lands on the ADOPTER, so it is worth stating: a handle built
|
|
21
|
+
* with NO schema (`drizzle(client)`, or `PostgresJsDatabase<Record<string, never>>`)
|
|
22
|
+
* is not assignable either, and the compiler explains it as
|
|
23
|
+
* `Seems like the schema generic is missing - did you forget to add it to your DB
|
|
24
|
+
* type?` — an error about `query`, on a store call that never touches the
|
|
25
|
+
* relational query builder. Pass your schema to `drizzle()` (or use
|
|
26
|
+
* `createDatabase({ databaseUrl, schema })` from `@oxy.so/db`) and it resolves.
|
|
27
|
+
* Both directions are verified in this package's own type-check.
|
|
28
|
+
*/
|
|
29
|
+
export type ModerationPgHandle = PgDatabase<
|
|
30
|
+
PgQueryResultHKT,
|
|
31
|
+
Record<string, unknown>,
|
|
32
|
+
TablesRelationalConfig
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The transaction runner, in Postgres.
|
|
37
|
+
*
|
|
38
|
+
* READ COMMITTED, explicitly, and not `repeatable read`. Neither multi-statement
|
|
39
|
+
* transaction this package runs — a report insert plus an outbox upsert, an event
|
|
40
|
+
* update plus an outbox upsert — reads-then-decides in a way snapshot isolation
|
|
41
|
+
* protects, so `repeatable read` would import `40001` serialization failures and
|
|
42
|
+
* a retry loop for no benefit.
|
|
43
|
+
*
|
|
44
|
+
* What it deliberately does NOT promise: intake's duplicate-check-then-insert is
|
|
45
|
+
* not serialized at READ COMMITTED, and the "one report per reporter per object"
|
|
46
|
+
* unique index is explicitly the application's responsibility.
|
|
47
|
+
*
|
|
48
|
+
* The failure this runner exists to make impossible is subtler than Mongo's. A
|
|
49
|
+
* store call given `db` instead of `tx` inside the callback runs on a DIFFERENT
|
|
50
|
+
* pooled connection and commits on its own — no error, no warning, and the
|
|
51
|
+
* atomicity the outbox exists for is simply gone. `postgresOutboxStore.enqueue`
|
|
52
|
+
* refuses a handle that is not a transaction for exactly that reason.
|
|
53
|
+
*/
|
|
54
|
+
export function postgresTransactionRunner(
|
|
55
|
+
db: ModerationPgHandle,
|
|
56
|
+
): ModerationTransactionRunner<ModerationPgHandle> {
|
|
57
|
+
return {
|
|
58
|
+
async run<T>(operation: (tx: ModerationPgHandle) => Promise<T>): Promise<T> {
|
|
59
|
+
return await db.transaction(async (tx) => await operation(tx), {
|
|
60
|
+
isolationLevel: 'read committed',
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|