@oimlsmart/platform-server 0.1.2 → 0.1.4
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/migrations/0018_notify_email.sql +51 -0
- package/migrations/0019_org_member_category.sql +34 -0
- package/package.json +1 -1
- package/src/store/d1.ts +154 -6
- package/src/store/sqlite/notify.ts +107 -0
- package/src/store/sqlite/schema.sql +39 -1
- package/src/store/sqlite/store.ts +35 -3
- package/src/store/sqlite.ts +43 -3
- package/src/store.ts +97 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- TODO.notify/04 — the email channel's delivery store (migration 0018
|
|
2
|
+
-- in the shared numbering; 0017 is the cones wave's — identity-features
|
|
3
|
+
-- /09). One row per (event, recipient) — the fan-out's record of WHO
|
|
4
|
+
-- the event reached and on WHICH channel mark (TODO.notify/00: "the
|
|
5
|
+
-- delivery lands on the audit/notification store (a notification row
|
|
6
|
+
-- per recipient with the channel marks)"):
|
|
7
|
+
--
|
|
8
|
+
-- reason the resolution's STRONGEST reason (subscribed /
|
|
9
|
+
-- assigned / author / actor / role-default) — every
|
|
10
|
+
-- notification carries its why, the delivery row
|
|
11
|
+
-- included;
|
|
12
|
+
-- email the resolved email posture (immediate | digest | off)
|
|
13
|
+
-- — the channel split's outcome (the inbox is the
|
|
14
|
+
-- constant, never a column);
|
|
15
|
+
-- email_status NULL when email = 'off'; else the email leg's state:
|
|
16
|
+
-- sent | failed | rate_limited for the immediate leg,
|
|
17
|
+
-- digest_pending → digest_sent | digest_failed for the
|
|
18
|
+
-- daily rollup, digest_dropped when the event left the
|
|
19
|
+
-- store before the rollup, 'unavailable' when the
|
|
20
|
+
-- instance carries NO mailer (the honest degradation:
|
|
21
|
+
-- nothing queues — a console-posture instance marks the
|
|
22
|
+
-- row unavailable at event time and the digest never
|
|
23
|
+
-- accumulates);
|
|
24
|
+
-- email_at the terminal stamp (the send / the drop), NULL while
|
|
25
|
+
-- pending.
|
|
26
|
+
--
|
|
27
|
+
-- The rows name the user + the event WITHOUT foreign keys (the
|
|
28
|
+
-- subscriptions store's own posture, migration 0014's: the user's
|
|
29
|
+
-- delivery record is their own, never the workflow's) — the demo reset
|
|
30
|
+
-- and the retention sweep delete events while a delivery row simply
|
|
31
|
+
-- never joins. The UNIQUE (event_id, user_id) makes the fan-out
|
|
32
|
+
-- idempotent (a re-driven event updates, never duplicates). The
|
|
33
|
+
-- mailer's own audit (entity_type 'email') stands alongside — this
|
|
34
|
+
-- table is the PER-RECIPIENT notification record, the audit trail the
|
|
35
|
+
-- per-SEND one. The package's migrations test pins this set's end state
|
|
36
|
+
-- to schema.sql (src/store/sqlite) in lockstep.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
event_id TEXT NOT NULL,
|
|
40
|
+
user_id TEXT NOT NULL,
|
|
41
|
+
reason TEXT NOT NULL,
|
|
42
|
+
email TEXT NOT NULL,
|
|
43
|
+
email_status TEXT,
|
|
44
|
+
email_at TEXT,
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
|
+
UNIQUE (event_id, user_id)
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id);
|
|
50
|
+
-- The digest rollup + the retry sweep read by status.
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Migration 0019 — the OIML Member category (TODO.identity-features/10,
|
|
2
|
+
-- the taxonomy correction): the org_registry gains the two designation
|
|
3
|
+
-- LINK columns and the CS status facet.
|
|
4
|
+
--
|
|
5
|
+
-- The corrected model: the Utilizer/Associate are DESIGNATED BODIES
|
|
6
|
+
-- (their own organization rows, signing the Declaration per PD-08),
|
|
7
|
+
-- never statuses on a member; "OIML Member" is the category with the
|
|
8
|
+
-- member-state / corresponding-member kinds. The links:
|
|
9
|
+
--
|
|
10
|
+
-- designated_by the designating body: a Utilizer's is its MEMBER
|
|
11
|
+
-- STATE, an Associate's its CORRESPONDING MEMBER, a
|
|
12
|
+
-- Test Laboratory's its associated ISSUING AUTHORITY
|
|
13
|
+
-- (the participants model's designated_by:
|
|
14
|
+
-- issuing_authority);
|
|
15
|
+
-- proposed_by an Issuing Authority's proposing MEMBER STATE (a
|
|
16
|
+
-- member state participates in the OIML-CS by
|
|
17
|
+
-- PROPOSING an IA and DESIGNATING a Utilizer);
|
|
18
|
+
-- cs_status the designated bodies' Declaration standing
|
|
19
|
+
-- (signed-active / suspended / withdrawn — the CS
|
|
20
|
+
-- layer's fact projected onto the identity plane).
|
|
21
|
+
--
|
|
22
|
+
-- Expand-only, and deliberately NULL-defaulted: the kind enforcement of
|
|
23
|
+
-- the links (which kind may point at which) is the PROGRAM's write path
|
|
24
|
+
-- (the store keeps the kind column opaque by doctrine), and a legacy
|
|
25
|
+
-- row — a utilizer/associate curated before this migration — keeps its
|
|
26
|
+
-- home untouched: the links read NULL ("not recorded"), never a
|
|
27
|
+
-- destructive move. The journals' discipline holds.
|
|
28
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
29
|
+
-- test/migrations.test.ts pins the UNION of every migration to
|
|
30
|
+
-- schema.sql's CREATE set.
|
|
31
|
+
|
|
32
|
+
ALTER TABLE org_registry ADD COLUMN designated_by TEXT;
|
|
33
|
+
ALTER TABLE org_registry ADD COLUMN proposed_by TEXT;
|
|
34
|
+
ALTER TABLE org_registry ADD COLUMN cs_status TEXT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/store/d1.ts
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type IdentityLink,
|
|
36
36
|
type IdentityProvider,
|
|
37
37
|
type MfaPending,
|
|
38
|
+
type NotifyDelivery,
|
|
39
|
+
type NotifyDeliveryStatus,
|
|
38
40
|
type NotifyEntityMute,
|
|
39
41
|
type NotifyInboxState,
|
|
40
42
|
type NotifyPreferences,
|
|
@@ -176,8 +178,9 @@ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?:
|
|
|
176
178
|
* (none WITHOUT ROWID — schema.sql). The TODO.notify/01 event store
|
|
177
179
|
* wipes with them — its rows reference the workflow entities a reset
|
|
178
180
|
* removes (the feed's read-time visibility gate would drop the orphans
|
|
179
|
-
* anyway; wiping keeps the demo honest).
|
|
180
|
-
|
|
181
|
+
* anyway; wiping keeps the demo honest). TODO.notify/04: the delivery
|
|
182
|
+
* store wipes alongside (its rows reference the wiped events). */
|
|
183
|
+
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
|
|
181
184
|
|
|
182
185
|
export class D1ServerStore implements ServerStore {
|
|
183
186
|
constructor(private readonly db: D1Database) {}
|
|
@@ -320,6 +323,16 @@ export class D1ServerStore implements ServerStore {
|
|
|
320
323
|
)`,
|
|
321
324
|
).run()
|
|
322
325
|
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
|
|
326
|
+
// TODO.identity-features/10 (the OIML Member category): the
|
|
327
|
+
// designation links + the CS status facet arrive with migration
|
|
328
|
+
// 0019 — a dev D1 predating it grows the columns here (the
|
|
329
|
+
// PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
|
|
330
|
+
// recorded: existing rows keep their posture silently.
|
|
331
|
+
const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
|
|
332
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
333
|
+
if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
|
|
334
|
+
if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
|
|
335
|
+
if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
|
|
323
336
|
})()
|
|
324
337
|
}
|
|
325
338
|
return this.orgRegistrySupportEnsured
|
|
@@ -2185,6 +2198,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
2185
2198
|
country: (row.country as string | null) ?? null,
|
|
2186
2199
|
contacts,
|
|
2187
2200
|
participantRef: (row.participant_ref as string | null) ?? null,
|
|
2201
|
+
designatedBy: (row.designated_by as string | null) ?? null,
|
|
2202
|
+
proposedBy: (row.proposed_by as string | null) ?? null,
|
|
2203
|
+
csStatus: (row.cs_status as string | null) ?? null,
|
|
2188
2204
|
state: row.state as OrgRegistryState,
|
|
2189
2205
|
createdAt: row.created_at as string,
|
|
2190
2206
|
createdBy: (row.created_by as string | null) ?? null,
|
|
@@ -2216,14 +2232,18 @@ export class D1ServerStore implements ServerStore {
|
|
|
2216
2232
|
country?: string | null
|
|
2217
2233
|
contacts?: OrgRegistryContact[]
|
|
2218
2234
|
participantRef?: string | null
|
|
2235
|
+
designatedBy?: string | null
|
|
2236
|
+
proposedBy?: string | null
|
|
2237
|
+
csStatus?: string | null
|
|
2219
2238
|
createdBy?: string | null
|
|
2220
2239
|
}): Promise<OrgRegistryOrg | null> {
|
|
2221
2240
|
await this.ensureOrgRegistrySupport()
|
|
2222
2241
|
const res = await this.stmt(
|
|
2223
|
-
`INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, created_by)
|
|
2224
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2242
|
+
`INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, designated_by, proposed_by, cs_status, created_by)
|
|
2243
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2225
2244
|
input.id, input.name, input.shortName ?? null, input.kind ?? null, input.country ?? null,
|
|
2226
|
-
JSON.stringify(input.contacts ?? []), input.participantRef ?? null,
|
|
2245
|
+
JSON.stringify(input.contacts ?? []), input.participantRef ?? null,
|
|
2246
|
+
input.designatedBy ?? null, input.proposedBy ?? null, input.csStatus ?? null, input.createdBy ?? null,
|
|
2227
2247
|
).run()
|
|
2228
2248
|
if ((res.meta.changes ?? 0) === 0) return null
|
|
2229
2249
|
return this.getOrgRegistryOrg(input.id)
|
|
@@ -2239,6 +2259,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
2239
2259
|
country?: string | null
|
|
2240
2260
|
contacts?: OrgRegistryContact[]
|
|
2241
2261
|
participantRef?: string | null
|
|
2262
|
+
designatedBy?: string | null
|
|
2263
|
+
proposedBy?: string | null
|
|
2264
|
+
csStatus?: string | null
|
|
2242
2265
|
},
|
|
2243
2266
|
actor?: string | null,
|
|
2244
2267
|
): Promise<OrgRegistryOrg | null> {
|
|
@@ -2251,6 +2274,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
2251
2274
|
if (patch.country !== undefined) { sets.push('country = ?'); params.push(patch.country) }
|
|
2252
2275
|
if (patch.contacts !== undefined) { sets.push('contacts = ?'); params.push(JSON.stringify(patch.contacts)) }
|
|
2253
2276
|
if (patch.participantRef !== undefined) { sets.push('participant_ref = ?'); params.push(patch.participantRef) }
|
|
2277
|
+
if (patch.designatedBy !== undefined) { sets.push('designated_by = ?'); params.push(patch.designatedBy) }
|
|
2278
|
+
if (patch.proposedBy !== undefined) { sets.push('proposed_by = ?'); params.push(patch.proposedBy) }
|
|
2279
|
+
if (patch.csStatus !== undefined) { sets.push('cs_status = ?'); params.push(patch.csStatus) }
|
|
2254
2280
|
sets.push("updated_at = datetime('now')", 'updated_by = ?')
|
|
2255
2281
|
params.push(actor ?? null)
|
|
2256
2282
|
const res = await this.stmt(`UPDATE org_registry SET ${sets.join(', ')} WHERE id = ?`, ...params, id).run()
|
|
@@ -2823,12 +2849,132 @@ export class D1ServerStore implements ServerStore {
|
|
|
2823
2849
|
return D1ServerStore.toNotifyInboxState(row!)
|
|
2824
2850
|
}
|
|
2825
2851
|
|
|
2852
|
+
// ── the email channel's delivery store (TODO.notify/04) ───────────
|
|
2853
|
+
// The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
|
|
2854
|
+
// The defensive ensure mirrors the instrument_registrations posture:
|
|
2855
|
+
// a dev D1 migrated from before migration 0018 lacks the table.
|
|
2856
|
+
|
|
2857
|
+
private notifyDeliverySupportEnsured: Promise<void> | null = null
|
|
2858
|
+
|
|
2859
|
+
private ensureNotifyDeliverySupport(): Promise<void> {
|
|
2860
|
+
if (!this.notifyDeliverySupportEnsured) {
|
|
2861
|
+
this.notifyDeliverySupportEnsured = (async () => {
|
|
2862
|
+
await this.db.prepare(
|
|
2863
|
+
`CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
2864
|
+
id TEXT PRIMARY KEY,
|
|
2865
|
+
event_id TEXT NOT NULL,
|
|
2866
|
+
user_id TEXT NOT NULL,
|
|
2867
|
+
reason TEXT NOT NULL,
|
|
2868
|
+
email TEXT NOT NULL,
|
|
2869
|
+
email_status TEXT,
|
|
2870
|
+
email_at TEXT,
|
|
2871
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2872
|
+
UNIQUE (event_id, user_id)
|
|
2873
|
+
)`,
|
|
2874
|
+
).run()
|
|
2875
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
|
|
2876
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
|
|
2877
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
|
|
2878
|
+
})()
|
|
2879
|
+
}
|
|
2880
|
+
return this.notifyDeliverySupportEnsured
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {
|
|
2884
|
+
return {
|
|
2885
|
+
id: row.id as string,
|
|
2886
|
+
eventId: row.event_id as string,
|
|
2887
|
+
userId: row.user_id as string,
|
|
2888
|
+
reason: row.reason as string,
|
|
2889
|
+
email: row.email as NotifyDelivery['email'],
|
|
2890
|
+
emailStatus: (row.email_status as NotifyDelivery['emailStatus']) ?? null,
|
|
2891
|
+
emailAt: (row.email_at as string | null) ?? null,
|
|
2892
|
+
createdAt: row.created_at as string,
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
async putNotifyDelivery(input: {
|
|
2897
|
+
id: string
|
|
2898
|
+
eventId: string
|
|
2899
|
+
userId: string
|
|
2900
|
+
reason: string
|
|
2901
|
+
email: NotifyDelivery['email']
|
|
2902
|
+
emailStatus: NotifyDelivery['emailStatus']
|
|
2903
|
+
}): Promise<NotifyDelivery> {
|
|
2904
|
+
await this.ensureNotifyDeliverySupport()
|
|
2905
|
+
await this.stmt(
|
|
2906
|
+
`INSERT INTO notify_deliveries (id, event_id, user_id, reason, email, email_status, email_at)
|
|
2907
|
+
VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL OR ? = 'digest_pending' THEN NULL ELSE datetime('now') END)
|
|
2908
|
+
ON CONFLICT (event_id, user_id) DO UPDATE SET
|
|
2909
|
+
reason = excluded.reason,
|
|
2910
|
+
email = excluded.email`,
|
|
2911
|
+
input.id, input.eventId, input.userId, input.reason, input.email, input.emailStatus, input.emailStatus, input.emailStatus,
|
|
2912
|
+
).run()
|
|
2913
|
+
const row = await this.stmt(
|
|
2914
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', input.eventId, input.userId,
|
|
2915
|
+
).first<Record<string, unknown>>()
|
|
2916
|
+
return D1ServerStore.toNotifyDelivery(row!)
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
async getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null> {
|
|
2920
|
+
await this.ensureNotifyDeliverySupport()
|
|
2921
|
+
const row = await this.stmt(
|
|
2922
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', eventId, userId,
|
|
2923
|
+
).first<Record<string, unknown>>()
|
|
2924
|
+
return row ? D1ServerStore.toNotifyDelivery(row) : null
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
async listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]> {
|
|
2928
|
+
await this.ensureNotifyDeliverySupport()
|
|
2929
|
+
const res = await this.stmt(
|
|
2930
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? ORDER BY created_at, id', eventId,
|
|
2931
|
+
).all<Record<string, unknown>>()
|
|
2932
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
async notifyDigestPendingUsers(): Promise<string[]> {
|
|
2936
|
+
await this.ensureNotifyDeliverySupport()
|
|
2937
|
+
const res = await this.stmt(
|
|
2938
|
+
"SELECT DISTINCT user_id FROM notify_deliveries WHERE email_status = 'digest_pending' ORDER BY user_id",
|
|
2939
|
+
).all<{ user_id: string }>()
|
|
2940
|
+
return res.results.map(r => r.user_id)
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
async notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]> {
|
|
2944
|
+
await this.ensureNotifyDeliverySupport()
|
|
2945
|
+
const res = await this.stmt(
|
|
2946
|
+
"SELECT * FROM notify_deliveries WHERE user_id = ? AND email_status = 'digest_pending' ORDER BY created_at, id", userId,
|
|
2947
|
+
).all<Record<string, unknown>>()
|
|
2948
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
async notifyFailedDeliveries(limit = 100): Promise<NotifyDelivery[]> {
|
|
2952
|
+
await this.ensureNotifyDeliverySupport()
|
|
2953
|
+
const res = await this.stmt(
|
|
2954
|
+
"SELECT * FROM notify_deliveries WHERE email_status = 'failed' ORDER BY created_at, id LIMIT ?", limit,
|
|
2955
|
+
).all<Record<string, unknown>>()
|
|
2956
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** The status mark: the terminal marks stamp email_at; a re-queue to
|
|
2960
|
+
* 'digest_pending' CLEARS it (a pending row carries no stamp). */
|
|
2961
|
+
async markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void> {
|
|
2962
|
+
await this.ensureNotifyDeliverySupport()
|
|
2963
|
+
if (status === 'digest_pending') {
|
|
2964
|
+
await this.stmt('UPDATE notify_deliveries SET email_status = ?, email_at = NULL WHERE id = ?', status, id).run()
|
|
2965
|
+
} else {
|
|
2966
|
+
await this.stmt("UPDATE notify_deliveries SET email_status = ?, email_at = datetime('now') WHERE id = ?", status, id).run()
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2826
2970
|
// ── provisioning / dev support ───────────────────────────────────
|
|
2827
2971
|
|
|
2828
2972
|
async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
|
|
2829
2973
|
// The register table joins the wipe defensively (a dev D1 migrated
|
|
2830
|
-
// from before migration 0016 lacks it — the ensure posture).
|
|
2974
|
+
// from before migration 0016 lacks it — the ensure posture). The
|
|
2975
|
+
// delivery store (0018) the same.
|
|
2831
2976
|
await this.ensureInstrumentRegistrationSupport()
|
|
2977
|
+
await this.ensureNotifyDeliverySupport()
|
|
2832
2978
|
// The wipe's tables in one batch (all-or-nothing, the putEntity
|
|
2833
2979
|
// pattern). A ranged round charges one bounded statement per table;
|
|
2834
2980
|
// the range-less form is the direct-call default (small stores,
|
|
@@ -2842,12 +2988,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
2842
2988
|
|
|
2843
2989
|
async workflowStoreRowCeiling(): Promise<number> {
|
|
2844
2990
|
await this.ensureInstrumentRegistrationSupport()
|
|
2991
|
+
await this.ensureNotifyDeliverySupport()
|
|
2845
2992
|
const row = await this.stmt(
|
|
2846
2993
|
`SELECT MAX(ceiling) AS ceiling FROM (
|
|
2847
2994
|
SELECT MAX(rowid) AS ceiling FROM entity_changes
|
|
2848
2995
|
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
2849
2996
|
UNION ALL SELECT MAX(rowid) FROM entities
|
|
2850
2997
|
UNION ALL SELECT MAX(rowid) FROM events
|
|
2998
|
+
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
2851
2999
|
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
2852
3000
|
).first<{ ceiling: number | null }>()
|
|
2853
3001
|
return row?.ceiling ?? 0
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
// per-event read/done markers the computed feed joins (one row per
|
|
17
17
|
// (user, event), written lazily at the first act on the row).
|
|
18
18
|
//
|
|
19
|
+
// Plus TODO.notify/04's delivery store: notify_deliveries, one row per
|
|
20
|
+
// (event, recipient) — the email channel's per-recipient record (the
|
|
21
|
+
// reason, the resolved posture, the email leg's state; the digest
|
|
22
|
+
// sweep's queue and the retry sweep's read).
|
|
23
|
+
//
|
|
19
24
|
// The D1 store (../d1.ts) runs the SAME statements against the binding;
|
|
20
25
|
// the d1-store suite's tripwire pins the two schemas in lockstep.
|
|
21
26
|
// ═══════════════════════════════════════════════════════════════════
|
|
22
27
|
|
|
23
28
|
import { getDb } from './store'
|
|
24
29
|
import type {
|
|
30
|
+
NotifyDelivery,
|
|
25
31
|
NotifyEntityMute,
|
|
26
32
|
NotifyInboxState,
|
|
27
33
|
NotifyPreferences,
|
|
@@ -245,3 +251,104 @@ export function putNotifyInboxState(input: {
|
|
|
245
251
|
.get(input.userId, input.eventId) as InboxStateRow,
|
|
246
252
|
)
|
|
247
253
|
}
|
|
254
|
+
|
|
255
|
+
// ── the email channel's delivery store (TODO.notify/04) ──────────────
|
|
256
|
+
|
|
257
|
+
interface DeliveryRow {
|
|
258
|
+
id: string
|
|
259
|
+
event_id: string
|
|
260
|
+
user_id: string
|
|
261
|
+
reason: string
|
|
262
|
+
email: string
|
|
263
|
+
email_status: string | null
|
|
264
|
+
email_at: string | null
|
|
265
|
+
created_at: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toNotifyDelivery(row: DeliveryRow): NotifyDelivery {
|
|
269
|
+
return {
|
|
270
|
+
id: row.id,
|
|
271
|
+
eventId: row.event_id,
|
|
272
|
+
userId: row.user_id,
|
|
273
|
+
reason: row.reason,
|
|
274
|
+
email: row.email as NotifyDelivery['email'],
|
|
275
|
+
emailStatus: row.email_status as NotifyDelivery['emailStatus'],
|
|
276
|
+
emailAt: row.email_at,
|
|
277
|
+
createdAt: row.created_at,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The fan-out's write: the upsert on UNIQUE (event_id, user_id). The
|
|
282
|
+
* status only ever moves FORWARD through the caller's marks — the
|
|
283
|
+
* upsert itself never overwrites a terminal stamp (a re-driven event
|
|
284
|
+
* refreshes reason + posture, keeps the email leg's state). An INITIAL
|
|
285
|
+
* write carrying a terminal status stamps email_at (pending states —
|
|
286
|
+
* NULL and 'digest_pending' — never carry a stamp). */
|
|
287
|
+
export function putNotifyDelivery(input: {
|
|
288
|
+
id: string
|
|
289
|
+
eventId: string
|
|
290
|
+
userId: string
|
|
291
|
+
reason: string
|
|
292
|
+
email: NotifyDelivery['email']
|
|
293
|
+
emailStatus: NotifyDelivery['emailStatus']
|
|
294
|
+
}): NotifyDelivery {
|
|
295
|
+
const db = getDb()
|
|
296
|
+
db.prepare(
|
|
297
|
+
`INSERT INTO notify_deliveries (id, event_id, user_id, reason, email, email_status, email_at)
|
|
298
|
+
VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL OR ? = 'digest_pending' THEN NULL ELSE datetime('now') END)
|
|
299
|
+
ON CONFLICT (event_id, user_id) DO UPDATE SET
|
|
300
|
+
reason = excluded.reason,
|
|
301
|
+
email = excluded.email`,
|
|
302
|
+
).run(input.id, input.eventId, input.userId, input.reason, input.email, input.emailStatus, input.emailStatus, input.emailStatus)
|
|
303
|
+
return toNotifyDelivery(
|
|
304
|
+
db.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?')
|
|
305
|
+
.get(input.eventId, input.userId) as DeliveryRow,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function getNotifyDelivery(eventId: string, userId: string): NotifyDelivery | null {
|
|
310
|
+
const row = getDb()
|
|
311
|
+
.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?')
|
|
312
|
+
.get(eventId, userId) as DeliveryRow | undefined
|
|
313
|
+
return row ? toNotifyDelivery(row) : null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function listNotifyDeliveriesForEvent(eventId: string): NotifyDelivery[] {
|
|
317
|
+
const rows = getDb()
|
|
318
|
+
.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? ORDER BY created_at, id')
|
|
319
|
+
.all(eventId) as DeliveryRow[]
|
|
320
|
+
return rows.map(toNotifyDelivery)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The digest sweep's first read: the DISTINCT users holding pending
|
|
324
|
+
* rows (one digest message per user per run). */
|
|
325
|
+
export function notifyDigestPendingUsers(): string[] {
|
|
326
|
+
const rows = getDb()
|
|
327
|
+
.prepare("SELECT DISTINCT user_id FROM notify_deliveries WHERE email_status = 'digest_pending' ORDER BY user_id")
|
|
328
|
+
.all() as Array<{ user_id: string }>
|
|
329
|
+
return rows.map(r => r.user_id)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function notifyDigestPendingForUser(userId: string): NotifyDelivery[] {
|
|
333
|
+
const rows = getDb()
|
|
334
|
+
.prepare("SELECT * FROM notify_deliveries WHERE user_id = ? AND email_status = 'digest_pending' ORDER BY created_at, id")
|
|
335
|
+
.all(userId) as DeliveryRow[]
|
|
336
|
+
return rows.map(toNotifyDelivery)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The retry sweep's read: the failed immediates, oldest first. */
|
|
340
|
+
export function notifyFailedDeliveries(limit = 100): NotifyDelivery[] {
|
|
341
|
+
const rows = getDb()
|
|
342
|
+
.prepare("SELECT * FROM notify_deliveries WHERE email_status = 'failed' ORDER BY created_at, id LIMIT ?")
|
|
343
|
+
.all(limit) as DeliveryRow[]
|
|
344
|
+
return rows.map(toNotifyDelivery)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The status mark. The terminal marks stamp email_at; a re-queue to
|
|
348
|
+
* 'digest_pending' CLEARS it (a pending row carries no stamp). */
|
|
349
|
+
export function markNotifyDelivery(id: string, status: NonNullable<NotifyDelivery['emailStatus']>): void {
|
|
350
|
+
const terminal = status === 'digest_pending' ? null : "datetime('now')"
|
|
351
|
+
getDb()
|
|
352
|
+
.prepare(`UPDATE notify_deliveries SET email_status = ?, email_at = ${terminal ?? 'NULL'} WHERE id = ?`)
|
|
353
|
+
.run(status, id)
|
|
354
|
+
}
|
|
@@ -220,6 +220,33 @@ CREATE TABLE IF NOT EXISTS notify_inbox_state (
|
|
|
220
220
|
);
|
|
221
221
|
CREATE INDEX IF NOT EXISTS idx_notify_inbox_state_user ON notify_inbox_state (user_id);
|
|
222
222
|
|
|
223
|
+
-- TODO.notify/04 — the email channel's delivery store (migration 0018):
|
|
224
|
+
-- one row per (event, recipient), the fan-out's record of who the event
|
|
225
|
+
-- reached and on which channel mark. reason is the resolution's
|
|
226
|
+
-- strongest reason; email the resolved posture (immediate | digest |
|
|
227
|
+
-- off — the inbox is the constant, never a column); email_status the
|
|
228
|
+
-- email leg's state (NULL when off; sent | failed | rate_limited;
|
|
229
|
+
-- digest_pending → digest_sent | digest_failed | digest_dropped;
|
|
230
|
+
-- 'unavailable' on a mailer-less instance — nothing queues there).
|
|
231
|
+
-- NO foreign keys (the subscriptions store's posture): a wiped event
|
|
232
|
+
-- simply never joins. UNIQUE (event_id, user_id) keeps the fan-out
|
|
233
|
+
-- idempotent. The mailer's per-SEND audit (entity_type 'email') stands
|
|
234
|
+
-- alongside — this table is the PER-RECIPIENT record.
|
|
235
|
+
CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
236
|
+
id TEXT PRIMARY KEY,
|
|
237
|
+
event_id TEXT NOT NULL,
|
|
238
|
+
user_id TEXT NOT NULL,
|
|
239
|
+
reason TEXT NOT NULL,
|
|
240
|
+
email TEXT NOT NULL,
|
|
241
|
+
email_status TEXT,
|
|
242
|
+
email_at TEXT,
|
|
243
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
244
|
+
UNIQUE (event_id, user_id)
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id);
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status);
|
|
249
|
+
|
|
223
250
|
-- The evidence store (TODO.ops/09 — the monitor daemon's durable
|
|
224
251
|
-- streams): append-only records across restarts. The adapter contract
|
|
225
252
|
-- (src/evidence-store/adapter.ts) is tiny: append, query, getByIds,
|
|
@@ -641,7 +668,7 @@ CREATE INDEX IF NOT EXISTS idx_mfa_pending_user ON mfa_pending (user_id);
|
|
|
641
668
|
-- registry never merge (the spec's §4), and a membership row's honesty
|
|
642
669
|
-- (its lifecycle state) never depends on a join.
|
|
643
670
|
-- The D1 migration set carries the identical end state
|
|
644
|
-
-- (0013_org_registry.sql).
|
|
671
|
+
-- (0013_org_registry.sql + 0019_org_member_category.sql).
|
|
645
672
|
-- ═══════════════════════════════════════════════════════════════════
|
|
646
673
|
CREATE TABLE IF NOT EXISTS org_registry (
|
|
647
674
|
id TEXT PRIMARY KEY,
|
|
@@ -657,6 +684,17 @@ CREATE TABLE IF NOT EXISTS org_registry (
|
|
|
657
684
|
-- The participant-link annotation (which participant record the org
|
|
658
685
|
-- mirrors); documentation only.
|
|
659
686
|
participant_ref TEXT,
|
|
687
|
+
-- The designation links + the CS status facet (0019_org_member_category.sql,
|
|
688
|
+
-- TODO.identity-features/10): a Utilizer's designated_by is its MEMBER
|
|
689
|
+
-- STATE, an Associate's its CORRESPONDING MEMBER, a Test Laboratory's
|
|
690
|
+
-- its associated ISSUING AUTHORITY; an Issuing Authority's proposed_by
|
|
691
|
+
-- is its proposing MEMBER STATE; cs_status is the designated bodies'
|
|
692
|
+
-- Declaration standing (signed-active / suspended / withdrawn). All
|
|
693
|
+
-- NULL = not recorded; the kind enforcement is the program's write
|
|
694
|
+
-- path, the store keeps the columns opaque.
|
|
695
|
+
designated_by TEXT,
|
|
696
|
+
proposed_by TEXT,
|
|
697
|
+
cs_status TEXT,
|
|
660
698
|
state TEXT NOT NULL DEFAULT 'active',
|
|
661
699
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
662
700
|
created_by TEXT,
|
|
@@ -115,6 +115,22 @@ function migrateAuthTables(db: Database.Database): void {
|
|
|
115
115
|
if (membershipCols.length && !membershipCols.some(c => c.name === 'cone')) {
|
|
116
116
|
db.exec('ALTER TABLE org_memberships ADD COLUMN cone TEXT')
|
|
117
117
|
}
|
|
118
|
+
// TODO.identity-features/10 (the OIML Member category): the
|
|
119
|
+
// designation links + the CS status facet arrive with migration 0019 —
|
|
120
|
+
// a dev file predating it grows the columns here. NULL = not recorded:
|
|
121
|
+
// existing rows keep their posture silently.
|
|
122
|
+
const registryCols = db.prepare('PRAGMA table_info(org_registry)').all() as Array<{ name: string }>
|
|
123
|
+
if (registryCols.length) {
|
|
124
|
+
if (!registryCols.some(c => c.name === 'designated_by')) {
|
|
125
|
+
db.exec('ALTER TABLE org_registry ADD COLUMN designated_by TEXT')
|
|
126
|
+
}
|
|
127
|
+
if (!registryCols.some(c => c.name === 'proposed_by')) {
|
|
128
|
+
db.exec('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT')
|
|
129
|
+
}
|
|
130
|
+
if (!registryCols.some(c => c.name === 'cs_status')) {
|
|
131
|
+
db.exec('ALTER TABLE org_registry ADD COLUMN cs_status TEXT')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
118
134
|
// TODO.identity-sso/02+03 (the strong-authentication wave): the amr
|
|
119
135
|
// provenance columns on sessions → codes → access tokens.
|
|
120
136
|
if (!sessionCols.some(c => c.name === 'amr')) {
|
|
@@ -1008,6 +1024,9 @@ interface OrgRegistryRow {
|
|
|
1008
1024
|
country: string | null
|
|
1009
1025
|
contacts: string
|
|
1010
1026
|
participant_ref: string | null
|
|
1027
|
+
designated_by: string | null
|
|
1028
|
+
proposed_by: string | null
|
|
1029
|
+
cs_status: string | null
|
|
1011
1030
|
state: OrgRegistryState
|
|
1012
1031
|
created_at: string
|
|
1013
1032
|
created_by: string | null
|
|
@@ -1042,6 +1061,9 @@ function orgRegistryPayload(row: OrgRegistryRow): OrgRegistryOrg {
|
|
|
1042
1061
|
country: row.country,
|
|
1043
1062
|
contacts: parseOrgContacts(row.contacts),
|
|
1044
1063
|
participantRef: row.participant_ref,
|
|
1064
|
+
designatedBy: row.designated_by ?? null,
|
|
1065
|
+
proposedBy: row.proposed_by ?? null,
|
|
1066
|
+
csStatus: row.cs_status ?? null,
|
|
1045
1067
|
state: row.state,
|
|
1046
1068
|
createdAt: row.created_at,
|
|
1047
1069
|
createdBy: row.created_by,
|
|
@@ -1071,14 +1093,18 @@ export function createOrgRegistryOrg(input: {
|
|
|
1071
1093
|
country?: string | null
|
|
1072
1094
|
contacts?: OrgRegistryContact[]
|
|
1073
1095
|
participantRef?: string | null
|
|
1096
|
+
designatedBy?: string | null
|
|
1097
|
+
proposedBy?: string | null
|
|
1098
|
+
csStatus?: string | null
|
|
1074
1099
|
createdBy?: string | null
|
|
1075
1100
|
}): OrgRegistryOrg | null {
|
|
1076
1101
|
const res = getDb().prepare(
|
|
1077
|
-
`INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, created_by)
|
|
1078
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1102
|
+
`INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, designated_by, proposed_by, cs_status, created_by)
|
|
1103
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1079
1104
|
).run(
|
|
1080
1105
|
input.id, input.name, input.shortName ?? null, input.kind ?? null, input.country ?? null,
|
|
1081
|
-
JSON.stringify(input.contacts ?? []), input.participantRef ?? null,
|
|
1106
|
+
JSON.stringify(input.contacts ?? []), input.participantRef ?? null,
|
|
1107
|
+
input.designatedBy ?? null, input.proposedBy ?? null, input.csStatus ?? null, input.createdBy ?? null,
|
|
1082
1108
|
)
|
|
1083
1109
|
if (res.changes === 0) return null
|
|
1084
1110
|
return getOrgRegistryOrg(input.id)
|
|
@@ -1095,6 +1121,9 @@ export function updateOrgRegistryOrg(
|
|
|
1095
1121
|
country?: string | null
|
|
1096
1122
|
contacts?: OrgRegistryContact[]
|
|
1097
1123
|
participantRef?: string | null
|
|
1124
|
+
designatedBy?: string | null
|
|
1125
|
+
proposedBy?: string | null
|
|
1126
|
+
csStatus?: string | null
|
|
1098
1127
|
},
|
|
1099
1128
|
actor?: string | null,
|
|
1100
1129
|
): OrgRegistryOrg | null {
|
|
@@ -1106,6 +1135,9 @@ export function updateOrgRegistryOrg(
|
|
|
1106
1135
|
if (patch.country !== undefined) { sets.push('country = ?'); params.push(patch.country) }
|
|
1107
1136
|
if (patch.contacts !== undefined) { sets.push('contacts = ?'); params.push(JSON.stringify(patch.contacts)) }
|
|
1108
1137
|
if (patch.participantRef !== undefined) { sets.push('participant_ref = ?'); params.push(patch.participantRef) }
|
|
1138
|
+
if (patch.designatedBy !== undefined) { sets.push('designated_by = ?'); params.push(patch.designatedBy) }
|
|
1139
|
+
if (patch.proposedBy !== undefined) { sets.push('proposed_by = ?'); params.push(patch.proposedBy) }
|
|
1140
|
+
if (patch.csStatus !== undefined) { sets.push('cs_status = ?'); params.push(patch.csStatus) }
|
|
1109
1141
|
sets.push("updated_at = datetime('now')", 'updated_by = ?')
|
|
1110
1142
|
params.push(actor ?? null)
|
|
1111
1143
|
const res = getDb().prepare(`UPDATE org_registry SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)
|
package/src/store/sqlite.ts
CHANGED
|
@@ -96,12 +96,19 @@ import {
|
|
|
96
96
|
import {
|
|
97
97
|
deleteNotifyEntityMute,
|
|
98
98
|
deleteNotifyRule,
|
|
99
|
+
getNotifyDelivery,
|
|
99
100
|
getNotifyPreferences,
|
|
101
|
+
listNotifyDeliveriesForEvent,
|
|
100
102
|
listNotifyEntityMutes,
|
|
101
103
|
listNotifyInboxStates,
|
|
102
104
|
listNotifyRules,
|
|
105
|
+
markNotifyDelivery,
|
|
106
|
+
notifyDigestPendingForUser,
|
|
107
|
+
notifyDigestPendingUsers,
|
|
103
108
|
notifyEntityMutesForEvent,
|
|
109
|
+
notifyFailedDeliveries,
|
|
104
110
|
notifyRulesForEvent,
|
|
111
|
+
putNotifyDelivery,
|
|
105
112
|
putNotifyEntityMute,
|
|
106
113
|
putNotifyInboxState,
|
|
107
114
|
putNotifyPreferences,
|
|
@@ -165,7 +172,7 @@ import {
|
|
|
165
172
|
updateOpAccount,
|
|
166
173
|
updateUserName,
|
|
167
174
|
} from './sqlite/op-accounts-store'
|
|
168
|
-
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
175
|
+
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
169
176
|
import {
|
|
170
177
|
advanceWebauthnCounter,
|
|
171
178
|
consumeMfaPending,
|
|
@@ -199,8 +206,10 @@ import {
|
|
|
199
206
|
* gate would drop the orphans anyway; wiping keeps the demo honest).
|
|
200
207
|
* TODO.register/03: the instrument register wipes too — the e2e
|
|
201
208
|
* isolation contract resets it with the rest of the mutable workflow
|
|
202
|
-
* state.
|
|
203
|
-
|
|
209
|
+
* state. TODO.notify/04: the delivery store wipes alongside — its rows
|
|
210
|
+
* reference the wiped events (the per-recipient record is the
|
|
211
|
+
* workflow's, never the user's own state). */
|
|
212
|
+
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
|
|
204
213
|
|
|
205
214
|
export function createSqliteServerStore(): ServerStore {
|
|
206
215
|
return {
|
|
@@ -962,6 +971,36 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
962
971
|
return putNotifyInboxState(input)
|
|
963
972
|
},
|
|
964
973
|
|
|
974
|
+
// ── the email channel's delivery store (TODO.notify/04) ──
|
|
975
|
+
async putNotifyDelivery(input: {
|
|
976
|
+
id: string
|
|
977
|
+
eventId: string
|
|
978
|
+
userId: string
|
|
979
|
+
reason: string
|
|
980
|
+
email: NotifyDelivery['email']
|
|
981
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
982
|
+
}): Promise<NotifyDelivery> {
|
|
983
|
+
return putNotifyDelivery(input)
|
|
984
|
+
},
|
|
985
|
+
async getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null> {
|
|
986
|
+
return getNotifyDelivery(eventId, userId)
|
|
987
|
+
},
|
|
988
|
+
async listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]> {
|
|
989
|
+
return listNotifyDeliveriesForEvent(eventId)
|
|
990
|
+
},
|
|
991
|
+
async notifyDigestPendingUsers(): Promise<string[]> {
|
|
992
|
+
return notifyDigestPendingUsers()
|
|
993
|
+
},
|
|
994
|
+
async notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]> {
|
|
995
|
+
return notifyDigestPendingForUser(userId)
|
|
996
|
+
},
|
|
997
|
+
async notifyFailedDeliveries(limit?: number): Promise<NotifyDelivery[]> {
|
|
998
|
+
return notifyFailedDeliveries(limit)
|
|
999
|
+
},
|
|
1000
|
+
async markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void> {
|
|
1001
|
+
markNotifyDelivery(id, status)
|
|
1002
|
+
},
|
|
1003
|
+
|
|
965
1004
|
// ── provisioning / dev support ──
|
|
966
1005
|
async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
|
|
967
1006
|
const db = getDb()
|
|
@@ -983,6 +1022,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
983
1022
|
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
984
1023
|
UNION ALL SELECT MAX(rowid) FROM entities
|
|
985
1024
|
UNION ALL SELECT MAX(rowid) FROM events
|
|
1025
|
+
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
986
1026
|
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
987
1027
|
).get() as { ceiling: number | null }
|
|
988
1028
|
return row.ceiling ?? 0
|
package/src/store.ts
CHANGED
|
@@ -278,6 +278,44 @@ export interface NotifyInboxState {
|
|
|
278
278
|
createdAt: string
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ── the email channel's delivery store (TODO.notify/04) ──────────────
|
|
282
|
+
|
|
283
|
+
/** The email leg's state on a delivery row: NULL when the resolved
|
|
284
|
+
* posture is 'off' (the inbox carries the event, the mailbox never
|
|
285
|
+
* does); 'sent' / 'failed' / 'rate_limited' for the immediate leg (the
|
|
286
|
+
* retry sweep re-attempts 'failed'); 'digest_pending' → 'digest_sent' /
|
|
287
|
+
* 'digest_failed' for the daily rollup ('digest_dropped' when the event
|
|
288
|
+
* left the store before the rollup ran); 'unavailable' when the
|
|
289
|
+
* instance carries NO mailer (the honest degradation: nothing queues —
|
|
290
|
+
* a console-posture instance marks the row at event time and the digest
|
|
291
|
+
* never accumulates). */
|
|
292
|
+
export type NotifyDeliveryStatus =
|
|
293
|
+
| 'sent' | 'failed' | 'rate_limited' | 'unavailable'
|
|
294
|
+
| 'digest_pending' | 'digest_sent' | 'digest_failed' | 'digest_dropped'
|
|
295
|
+
|
|
296
|
+
/** One delivery row (TODO.notify/00: "a notification row per recipient
|
|
297
|
+
* with the channel marks"): the fan-out's record that the event reached
|
|
298
|
+
* this user — the resolution's STRONGEST reason, the resolved email
|
|
299
|
+
* posture, and the email leg's state. The inbox is the constant, never
|
|
300
|
+
* a column. UNIQUE (event_id, user_id): a re-driven event updates, never
|
|
301
|
+
* duplicates. NO foreign keys (the subscriptions store's posture) — a
|
|
302
|
+
* wiped event simply never joins. The mailer's per-SEND audit
|
|
303
|
+
* (entity_type 'email') stands alongside; this table is the
|
|
304
|
+
* PER-RECIPIENT record. */
|
|
305
|
+
export interface NotifyDelivery {
|
|
306
|
+
id: string
|
|
307
|
+
eventId: string
|
|
308
|
+
userId: string
|
|
309
|
+
/** The resolution's strongest reason (NOTIFY_REASON_PRECEDENCE's first). */
|
|
310
|
+
reason: string
|
|
311
|
+
/** The resolved email posture (the channel split's outcome). */
|
|
312
|
+
email: NotifyChannelPreference
|
|
313
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
314
|
+
/** The terminal stamp (the send / the drop); NULL while pending. */
|
|
315
|
+
emailAt: string | null
|
|
316
|
+
createdAt: string
|
|
317
|
+
}
|
|
318
|
+
|
|
281
319
|
// ── the OIDC Provider (TODO.identity/01) ─────────────────────────────
|
|
282
320
|
|
|
283
321
|
/** A registered relying party (an instance allowed to request tokens).
|
|
@@ -860,19 +898,40 @@ export interface OrgRegistryContact {
|
|
|
860
898
|
* scheme consumer); the program side bounds the assignable per-org
|
|
861
899
|
* roles by it. `participantRef` is the OPTIONAL annotation documenting
|
|
862
900
|
* which participant record the org mirrors (the link's documentation,
|
|
863
|
-
* never a key the store resolves).
|
|
901
|
+
* never a key the store resolves).
|
|
902
|
+
*
|
|
903
|
+
* TODO.identity-features/10 (the OIML Member category, the taxonomy
|
|
904
|
+
* correction): the designation links + the CS status facet (migration
|
|
905
|
+
* 0019). `designatedBy` is the designating body (a Utilizer's member
|
|
906
|
+
* state, an Associate's corresponding member, a Test Laboratory's
|
|
907
|
+
* associated issuing authority); `proposedBy` is an Issuing
|
|
908
|
+
* Authority's proposing member state; `csStatus` is the designated
|
|
909
|
+
* bodies' Declaration standing ('signed-active' | 'suspended' |
|
|
910
|
+
* 'withdrawn'). All three are opaque strings to the store — the
|
|
911
|
+
* per-kind link enforcement (which kind may point at which) is the
|
|
912
|
+
* program's write path; NULL reads "not recorded", honestly. */
|
|
864
913
|
export interface OrgRegistryOrg {
|
|
865
914
|
id: string
|
|
866
915
|
/** The display name. */
|
|
867
916
|
name: string
|
|
868
917
|
shortName: string | null
|
|
869
918
|
/** The participant kind ('issuing-authority' | 'test-laboratory' |
|
|
870
|
-
* 'utilizer' | 'associate' on the OIML-CS program
|
|
919
|
+
* 'utilizer' | 'associate' on the OIML-CS program, 'member-state' |
|
|
920
|
+
* 'corresponding-member' on the OIML Member category), NULL for a
|
|
871
921
|
* non-participant org. Opaque to the store. */
|
|
872
922
|
kind: string | null
|
|
873
923
|
country: string | null
|
|
874
924
|
contacts: OrgRegistryContact[]
|
|
875
925
|
participantRef: string | null
|
|
926
|
+
/** The designating body's org id (TODO.identity-features/10) — the
|
|
927
|
+
* designated-body kinds carry it; NULL = not recorded. */
|
|
928
|
+
designatedBy: string | null
|
|
929
|
+
/** The proposing member state's org id (the issuing-authority kind). */
|
|
930
|
+
proposedBy: string | null
|
|
931
|
+
/** The Declaration's standing on the designated bodies
|
|
932
|
+
* ('signed-active' | 'suspended' | 'withdrawn'); NULL = not
|
|
933
|
+
* recorded. */
|
|
934
|
+
csStatus: string | null
|
|
876
935
|
state: OrgRegistryState
|
|
877
936
|
createdAt: string
|
|
878
937
|
createdBy: string | null
|
|
@@ -1664,6 +1723,9 @@ export interface ServerStore {
|
|
|
1664
1723
|
country?: string | null
|
|
1665
1724
|
contacts?: OrgRegistryContact[]
|
|
1666
1725
|
participantRef?: string | null
|
|
1726
|
+
designatedBy?: string | null
|
|
1727
|
+
proposedBy?: string | null
|
|
1728
|
+
csStatus?: string | null
|
|
1667
1729
|
createdBy?: string | null
|
|
1668
1730
|
}): Promise<OrgRegistryOrg | null>
|
|
1669
1731
|
/** Edit the display data (the id is the stable slug — never editable);
|
|
@@ -1678,6 +1740,9 @@ export interface ServerStore {
|
|
|
1678
1740
|
country?: string | null
|
|
1679
1741
|
contacts?: OrgRegistryContact[]
|
|
1680
1742
|
participantRef?: string | null
|
|
1743
|
+
designatedBy?: string | null
|
|
1744
|
+
proposedBy?: string | null
|
|
1745
|
+
csStatus?: string | null
|
|
1681
1746
|
},
|
|
1682
1747
|
actor?: string | null,
|
|
1683
1748
|
): Promise<OrgRegistryOrg | null>
|
|
@@ -1873,6 +1938,36 @@ export interface ServerStore {
|
|
|
1873
1938
|
done?: boolean
|
|
1874
1939
|
}): Promise<NotifyInboxState>
|
|
1875
1940
|
|
|
1941
|
+
// ── the email channel's delivery store (TODO.notify/04) ──
|
|
1942
|
+
/** The fan-out's write (the upsert on UNIQUE (event_id, user_id) — a
|
|
1943
|
+
* re-driven event updates, never duplicates). Answers the stored row. */
|
|
1944
|
+
putNotifyDelivery(input: {
|
|
1945
|
+
id: string
|
|
1946
|
+
eventId: string
|
|
1947
|
+
userId: string
|
|
1948
|
+
reason: string
|
|
1949
|
+
email: NotifyChannelPreference
|
|
1950
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
1951
|
+
}): Promise<NotifyDelivery>
|
|
1952
|
+
/** The by-pair read (the idempotence leg + the proofs). */
|
|
1953
|
+
getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null>
|
|
1954
|
+
/** One event's delivery rows (the proofs; the fan-out's own log). */
|
|
1955
|
+
listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]>
|
|
1956
|
+
/** THE DIGEST SWEEP's reads: the users holding digest-pending rows,
|
|
1957
|
+
* then one user's pending rows (created_at order — the day's story). */
|
|
1958
|
+
notifyDigestPendingUsers(): Promise<string[]>
|
|
1959
|
+
notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]>
|
|
1960
|
+
/** THE RETRY SWEEP's read: the failed immediate rows, oldest first
|
|
1961
|
+
* (bounded — the sweep re-attempts per run, the mailer's rate limits
|
|
1962
|
+
* stand per attempt). */
|
|
1963
|
+
notifyFailedDeliveries(limit?: number): Promise<NotifyDelivery[]>
|
|
1964
|
+
/** The terminal/pending mark: the status + the stamp (email_at sets on
|
|
1965
|
+
* the terminal marks — sent/failed/rate_limited/unavailable/
|
|
1966
|
+
* digest_sent/digest_failed/digest_dropped — and CLEARS back to NULL
|
|
1967
|
+
* when a row re-enters digest_pending, so a retry's re-queue is
|
|
1968
|
+
* honest). */
|
|
1969
|
+
markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void>
|
|
1970
|
+
|
|
1876
1971
|
// ── provisioning / dev support ──
|
|
1877
1972
|
/** The mutable workflow stores emptied (the dev-reset + demo reseed
|
|
1878
1973
|
* leg): entities, entity_changes, evidence_records, events, and
|