@oimlsmart/platform-server 0.1.2 → 0.1.3

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.
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
- const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations'] as const
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) {}
@@ -2823,12 +2826,132 @@ export class D1ServerStore implements ServerStore {
2823
2826
  return D1ServerStore.toNotifyInboxState(row!)
2824
2827
  }
2825
2828
 
2829
+ // ── the email channel's delivery store (TODO.notify/04) ───────────
2830
+ // The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
2831
+ // The defensive ensure mirrors the instrument_registrations posture:
2832
+ // a dev D1 migrated from before migration 0018 lacks the table.
2833
+
2834
+ private notifyDeliverySupportEnsured: Promise<void> | null = null
2835
+
2836
+ private ensureNotifyDeliverySupport(): Promise<void> {
2837
+ if (!this.notifyDeliverySupportEnsured) {
2838
+ this.notifyDeliverySupportEnsured = (async () => {
2839
+ await this.db.prepare(
2840
+ `CREATE TABLE IF NOT EXISTS notify_deliveries (
2841
+ id TEXT PRIMARY KEY,
2842
+ event_id TEXT NOT NULL,
2843
+ user_id TEXT NOT NULL,
2844
+ reason TEXT NOT NULL,
2845
+ email TEXT NOT NULL,
2846
+ email_status TEXT,
2847
+ email_at TEXT,
2848
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2849
+ UNIQUE (event_id, user_id)
2850
+ )`,
2851
+ ).run()
2852
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
2853
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
2854
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
2855
+ })()
2856
+ }
2857
+ return this.notifyDeliverySupportEnsured
2858
+ }
2859
+
2860
+ private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {
2861
+ return {
2862
+ id: row.id as string,
2863
+ eventId: row.event_id as string,
2864
+ userId: row.user_id as string,
2865
+ reason: row.reason as string,
2866
+ email: row.email as NotifyDelivery['email'],
2867
+ emailStatus: (row.email_status as NotifyDelivery['emailStatus']) ?? null,
2868
+ emailAt: (row.email_at as string | null) ?? null,
2869
+ createdAt: row.created_at as string,
2870
+ }
2871
+ }
2872
+
2873
+ async putNotifyDelivery(input: {
2874
+ id: string
2875
+ eventId: string
2876
+ userId: string
2877
+ reason: string
2878
+ email: NotifyDelivery['email']
2879
+ emailStatus: NotifyDelivery['emailStatus']
2880
+ }): Promise<NotifyDelivery> {
2881
+ await this.ensureNotifyDeliverySupport()
2882
+ await this.stmt(
2883
+ `INSERT INTO notify_deliveries (id, event_id, user_id, reason, email, email_status, email_at)
2884
+ VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL OR ? = 'digest_pending' THEN NULL ELSE datetime('now') END)
2885
+ ON CONFLICT (event_id, user_id) DO UPDATE SET
2886
+ reason = excluded.reason,
2887
+ email = excluded.email`,
2888
+ input.id, input.eventId, input.userId, input.reason, input.email, input.emailStatus, input.emailStatus, input.emailStatus,
2889
+ ).run()
2890
+ const row = await this.stmt(
2891
+ 'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', input.eventId, input.userId,
2892
+ ).first<Record<string, unknown>>()
2893
+ return D1ServerStore.toNotifyDelivery(row!)
2894
+ }
2895
+
2896
+ async getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null> {
2897
+ await this.ensureNotifyDeliverySupport()
2898
+ const row = await this.stmt(
2899
+ 'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', eventId, userId,
2900
+ ).first<Record<string, unknown>>()
2901
+ return row ? D1ServerStore.toNotifyDelivery(row) : null
2902
+ }
2903
+
2904
+ async listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]> {
2905
+ await this.ensureNotifyDeliverySupport()
2906
+ const res = await this.stmt(
2907
+ 'SELECT * FROM notify_deliveries WHERE event_id = ? ORDER BY created_at, id', eventId,
2908
+ ).all<Record<string, unknown>>()
2909
+ return res.results.map(D1ServerStore.toNotifyDelivery)
2910
+ }
2911
+
2912
+ async notifyDigestPendingUsers(): Promise<string[]> {
2913
+ await this.ensureNotifyDeliverySupport()
2914
+ const res = await this.stmt(
2915
+ "SELECT DISTINCT user_id FROM notify_deliveries WHERE email_status = 'digest_pending' ORDER BY user_id",
2916
+ ).all<{ user_id: string }>()
2917
+ return res.results.map(r => r.user_id)
2918
+ }
2919
+
2920
+ async notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]> {
2921
+ await this.ensureNotifyDeliverySupport()
2922
+ const res = await this.stmt(
2923
+ "SELECT * FROM notify_deliveries WHERE user_id = ? AND email_status = 'digest_pending' ORDER BY created_at, id", userId,
2924
+ ).all<Record<string, unknown>>()
2925
+ return res.results.map(D1ServerStore.toNotifyDelivery)
2926
+ }
2927
+
2928
+ async notifyFailedDeliveries(limit = 100): Promise<NotifyDelivery[]> {
2929
+ await this.ensureNotifyDeliverySupport()
2930
+ const res = await this.stmt(
2931
+ "SELECT * FROM notify_deliveries WHERE email_status = 'failed' ORDER BY created_at, id LIMIT ?", limit,
2932
+ ).all<Record<string, unknown>>()
2933
+ return res.results.map(D1ServerStore.toNotifyDelivery)
2934
+ }
2935
+
2936
+ /** The status mark: the terminal marks stamp email_at; a re-queue to
2937
+ * 'digest_pending' CLEARS it (a pending row carries no stamp). */
2938
+ async markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void> {
2939
+ await this.ensureNotifyDeliverySupport()
2940
+ if (status === 'digest_pending') {
2941
+ await this.stmt('UPDATE notify_deliveries SET email_status = ?, email_at = NULL WHERE id = ?', status, id).run()
2942
+ } else {
2943
+ await this.stmt("UPDATE notify_deliveries SET email_status = ?, email_at = datetime('now') WHERE id = ?", status, id).run()
2944
+ }
2945
+ }
2946
+
2826
2947
  // ── provisioning / dev support ───────────────────────────────────
2827
2948
 
2828
2949
  async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
2829
2950
  // The register table joins the wipe defensively (a dev D1 migrated
2830
- // from before migration 0016 lacks it — the ensure posture).
2951
+ // from before migration 0016 lacks it — the ensure posture). The
2952
+ // delivery store (0018) the same.
2831
2953
  await this.ensureInstrumentRegistrationSupport()
2954
+ await this.ensureNotifyDeliverySupport()
2832
2955
  // The wipe's tables in one batch (all-or-nothing, the putEntity
2833
2956
  // pattern). A ranged round charges one bounded statement per table;
2834
2957
  // the range-less form is the direct-call default (small stores,
@@ -2842,12 +2965,14 @@ export class D1ServerStore implements ServerStore {
2842
2965
 
2843
2966
  async workflowStoreRowCeiling(): Promise<number> {
2844
2967
  await this.ensureInstrumentRegistrationSupport()
2968
+ await this.ensureNotifyDeliverySupport()
2845
2969
  const row = await this.stmt(
2846
2970
  `SELECT MAX(ceiling) AS ceiling FROM (
2847
2971
  SELECT MAX(rowid) AS ceiling FROM entity_changes
2848
2972
  UNION ALL SELECT MAX(rowid) FROM evidence_records
2849
2973
  UNION ALL SELECT MAX(rowid) FROM entities
2850
2974
  UNION ALL SELECT MAX(rowid) FROM events
2975
+ UNION ALL SELECT MAX(rowid) FROM notify_deliveries
2851
2976
  UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
2852
2977
  ).first<{ ceiling: number | null }>()
2853
2978
  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,
@@ -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
- const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations'] as const
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).
@@ -1873,6 +1911,36 @@ export interface ServerStore {
1873
1911
  done?: boolean
1874
1912
  }): Promise<NotifyInboxState>
1875
1913
 
1914
+ // ── the email channel's delivery store (TODO.notify/04) ──
1915
+ /** The fan-out's write (the upsert on UNIQUE (event_id, user_id) — a
1916
+ * re-driven event updates, never duplicates). Answers the stored row. */
1917
+ putNotifyDelivery(input: {
1918
+ id: string
1919
+ eventId: string
1920
+ userId: string
1921
+ reason: string
1922
+ email: NotifyChannelPreference
1923
+ emailStatus: NotifyDeliveryStatus | null
1924
+ }): Promise<NotifyDelivery>
1925
+ /** The by-pair read (the idempotence leg + the proofs). */
1926
+ getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null>
1927
+ /** One event's delivery rows (the proofs; the fan-out's own log). */
1928
+ listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]>
1929
+ /** THE DIGEST SWEEP's reads: the users holding digest-pending rows,
1930
+ * then one user's pending rows (created_at order — the day's story). */
1931
+ notifyDigestPendingUsers(): Promise<string[]>
1932
+ notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]>
1933
+ /** THE RETRY SWEEP's read: the failed immediate rows, oldest first
1934
+ * (bounded — the sweep re-attempts per run, the mailer's rate limits
1935
+ * stand per attempt). */
1936
+ notifyFailedDeliveries(limit?: number): Promise<NotifyDelivery[]>
1937
+ /** The terminal/pending mark: the status + the stamp (email_at sets on
1938
+ * the terminal marks — sent/failed/rate_limited/unavailable/
1939
+ * digest_sent/digest_failed/digest_dropped — and CLEARS back to NULL
1940
+ * when a row re-enters digest_pending, so a retry's re-queue is
1941
+ * honest). */
1942
+ markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void>
1943
+
1876
1944
  // ── provisioning / dev support ──
1877
1945
  /** The mutable workflow stores emptied (the dev-reset + demo reseed
1878
1946
  * leg): entities, entity_changes, evidence_records, events, and