@oimlsmart/platform-server 0.2.12 → 0.2.13

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,22 @@
1
+ -- The public register's certificate-number lookup (the ServerStore
2
+ -- seam's findCertificatesByNumber): the smart side's verify surfaces
3
+ -- (/api/verify/status?number=, the credential verify's register
4
+ -- cross-check) full-scanned listEntities('certificates') and
5
+ -- JSON-parsed every row to case-fold certificate_number — O(store
6
+ -- rows) read per lookup. The guarded expression index (migration
7
+ -- 0023's spelling) makes the keyed read an index walk: (store, the
8
+ -- certificate_number out of the data JSON) — COLLATE NOCASE because
9
+ -- the register's number match is case-insensitive (the ASCII fold;
10
+ -- certificate numbers are ASCII by the number grammar).
11
+ --
12
+ -- The json_valid GUARD is load-bearing (0023's lesson): an unguarded
13
+ -- json_extract index expression would raise on the INSERT of a corrupt
14
+ -- entities row; the guard keeps a corrupt row writable.
15
+ --
16
+ -- CREATE INDEX IF NOT EXISTS is the idempotent guard (the migration
17
+ -- contract's expand-only discipline; a consumer that already carries
18
+ -- the index converges). schema.sql's mirror lands in the same commit.
19
+ CREATE INDEX IF NOT EXISTS idx_entities_store_certificate_number ON entities (
20
+ store,
21
+ json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE
22
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
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
@@ -33,6 +33,7 @@ import {
33
33
  APPEND_EVENTS_CHUNK,
34
34
  DEMO_PASSWORD,
35
35
  EVENTS_BULK_KEY_CHUNK,
36
+ EVENTS_ID_CHUNK,
36
37
  INSTRUMENT_REGISTRATIONS_CHUNK,
37
38
  PUT_ENTITIES_CHUNK,
38
39
  StoreUnavailable,
@@ -88,6 +89,7 @@ import {
88
89
  type InstrumentRegistrationLifecycle,
89
90
  type InstrumentRegistrationScopeStatus,
90
91
  type InstrumentRegistrationWriteInput,
92
+ type JournalAppend,
91
93
  type PersonalAccessToken,
92
94
  type PlatformEvent,
93
95
  resolveOrgContext,
@@ -427,6 +429,43 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
427
429
  ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
428
430
  const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
429
431
 
432
+ /** The journal fan-out's ISOLATE-scope registry (the seam's
433
+ * onJournalAppend): one module-scope set — a registration through ANY
434
+ * D1ServerStore instance hears every journal append landing in this
435
+ * isolate, whichever instance wrote it. Cross-isolate writes never
436
+ * fire it (the database is the source of truth; the consumer's poll
437
+ * stays the fallback). */
438
+ const journalListeners = new Set<(appends: readonly JournalAppend[]) => void>()
439
+
440
+ /** Fires the registry with one write's appended triples, AFTER the
441
+ * write stands (the batch resolved / the row gone). Synchronous, in
442
+ * the write's continuation; a listener's throw is swallowed per
443
+ * listener — the write path never breaks for a listener. The snapshot
444
+ * keeps an unregistering listener's removal honest mid-emit. */
445
+ function emitJournalAppends(appends: readonly JournalAppend[]): void {
446
+ if (appends.length === 0 || journalListeners.size === 0) return
447
+ for (const listener of [...journalListeners]) {
448
+ try {
449
+ listener(appends)
450
+ } catch { /* a listener never breaks the write path */ }
451
+ }
452
+ }
453
+
454
+ /** The register's number lookup (findCertificatesByNumber): the keyed
455
+ * read against idx_entities_store_certificate_number (migration 0028)
456
+ * — the json_valid-guarded extract's exact spelling, COLLATE NOCASE
457
+ * for the register's case-insensitive number match. INDEXED BY pins
458
+ * the walk: without it the planner prefers idx_entities_store_org for
459
+ * the ORDER BY (org_id, rowid) — the seam's list order, kept so the
460
+ * first match IS the retiring listEntities scan's first match — and
461
+ * the "index" would walk the whole store; with it a database behind
462
+ * migration 0028 errors honestly, never scans silently. */
463
+ const CERTIFICATE_NUMBER_SQL = `SELECT store, id, org_id, data, updated_at
464
+ FROM entities INDEXED BY idx_entities_store_certificate_number
465
+ WHERE store = 'certificates'
466
+ AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE = ?
467
+ ORDER BY org_id, rowid`
468
+
430
469
  /** The register write's INSERT — ONE textual source for
431
470
  * createInstrumentRegistration and createInstrumentRegistrations alike
432
471
  * (the batch verb appends RETURNING * so the stored row answers off
@@ -3525,6 +3564,11 @@ export class D1ServerStore implements ServerStore {
3525
3564
  return row ?? undefined
3526
3565
  }
3527
3566
 
3567
+ async findCertificatesByNumber(number: string): Promise<EntityRow[]> {
3568
+ const res = await this.stmt(CERTIFICATE_NUMBER_SQL, number).all<EntityRow>()
3569
+ return res.results
3570
+ }
3571
+
3528
3572
  async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
3529
3573
  // The upsert + its journal entry ride ONE batch — D1 batches are
3530
3574
  // all-or-nothing, the same atomicity the SQLite path gets from its
@@ -3533,6 +3577,7 @@ export class D1ServerStore implements ServerStore {
3533
3577
  this.stmt(ENTITY_UPSERT_SQL, store, id, orgId, data),
3534
3578
  this.stmt(ENTITY_CHANGE_SQL, store, 'persist', id),
3535
3579
  ])
3580
+ emitJournalAppends([{ store, type: 'persist', id }])
3536
3581
  }
3537
3582
 
3538
3583
  async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
@@ -3555,6 +3600,10 @@ export class D1ServerStore implements ServerStore {
3555
3600
  )
3556
3601
  }
3557
3602
  await this.db.batch(statements)
3603
+ // The fan-out fires per LANDED chunk (the triples in input order)
3604
+ // — a failed chunk throws before its emit, so a listener never
3605
+ // hears of rows that did not stand.
3606
+ emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
3558
3607
  }
3559
3608
  }
3560
3609
 
@@ -3563,6 +3612,7 @@ export class D1ServerStore implements ServerStore {
3563
3612
  const gone = (res.meta.changes ?? 0) > 0
3564
3613
  if (gone) {
3565
3614
  await this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'remove', id).run()
3615
+ emitJournalAppends([{ store, type: 'remove', id }])
3566
3616
  }
3567
3617
  return gone
3568
3618
  }
@@ -3589,6 +3639,11 @@ export class D1ServerStore implements ServerStore {
3589
3639
  return row?.seq ?? 0
3590
3640
  }
3591
3641
 
3642
+ onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void {
3643
+ journalListeners.add(listener)
3644
+ return () => { journalListeners.delete(listener) }
3645
+ }
3646
+
3592
3647
  // ── the platform event store (TODO.notify/01) ─────────────────────
3593
3648
  // The event rows port directly (D1 is SQLite) — the same statements
3594
3649
  // as sqlite/events.ts's sync half.
@@ -3659,6 +3714,28 @@ export class D1ServerStore implements ServerStore {
3659
3714
  return row ? D1ServerStore.toPlatformEvent(row) : null
3660
3715
  }
3661
3716
 
3717
+ /** The bulk by-id read (the seam's contract, mechanically): every id
3718
+ * resolves in ONE statement per EVENTS_ID_CHUNK ids — the IN walk
3719
+ * against the id UNIQUE index (never a compound SELECT: the D1
3720
+ * 5-term cap rule), the chunks serial — and the answer is
3721
+ * INPUT-ALIGNED: position i carries the row for ids[i], null where
3722
+ * no event carries the id (the per-id getEvent loop's exact
3723
+ * answers; a duplicate id answers its row at every position). An
3724
+ * empty list answers [] without issuing a statement. */
3725
+ async getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]> {
3726
+ if (ids.length === 0) return []
3727
+ const byId = new Map<string, PlatformEvent>()
3728
+ for (let i = 0; i < ids.length; i += EVENTS_ID_CHUNK) {
3729
+ const chunk = ids.slice(i, i + EVENTS_ID_CHUNK)
3730
+ const marks = chunk.map(() => '?').join(', ')
3731
+ const res = await this.stmt(
3732
+ `SELECT * FROM events WHERE id IN (${marks})`, ...chunk,
3733
+ ).all<Record<string, unknown>>()
3734
+ for (const row of res.results) byId.set(row.id as string, D1ServerStore.toPlatformEvent(row))
3735
+ }
3736
+ return ids.map(id => byId.get(id) ?? null)
3737
+ }
3738
+
3662
3739
  /** The subscription grammar's SQL resolution: the pinned columns match
3663
3740
  * by equality; the free legs stay out of the WHERE. The BULK form
3664
3741
  * (the 2026-09-06 audit's notify-inbox seam) resolves every pinned
@@ -18,7 +18,33 @@ import { PUT_ENTITIES_CHUNK, orgIdOf } from '../../store'
18
18
  export type { EntityRow, EntityChange } from '../../store'
19
19
  export { ORG_FIELDS, CATALOG_STORES, orgIdOf } from '../../store'
20
20
 
21
- import type { EntityRow, EntityChange, EntityWriteInput } from '../../store'
21
+ import type { EntityRow, EntityChange, EntityWriteInput, JournalAppend } from '../../store'
22
+
23
+ /** The journal fan-out's ISOLATE-scope registry (the seam's
24
+ * onJournalAppend, the D1 half's twin): one module-scope set — a
25
+ * registration through any store instance hears every journal append
26
+ * landing in this process. */
27
+ const journalListeners = new Set<(appends: readonly JournalAppend[]) => void>()
28
+
29
+ /** The seam's registration verb: the answer is the unregister
30
+ * (idempotent). */
31
+ export function onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void {
32
+ journalListeners.add(listener)
33
+ return () => { journalListeners.delete(listener) }
34
+ }
35
+
36
+ /** Fires the registry with one write's appended triples, AFTER the
37
+ * write stands (the transaction committed). A listener's throw is
38
+ * swallowed per listener — the write path never breaks for a
39
+ * listener. */
40
+ function emitJournalAppends(appends: readonly JournalAppend[]): void {
41
+ if (appends.length === 0 || journalListeners.size === 0) return
42
+ for (const listener of [...journalListeners]) {
43
+ try {
44
+ listener(appends)
45
+ } catch { /* a listener never breaks the write path */ }
46
+ }
47
+ }
22
48
 
23
49
  /** The entity write's two statements, ONE textual source for putEntity
24
50
  * and putEntities alike (the multi-row write must land each row
@@ -28,6 +54,21 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
28
54
  ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
29
55
  const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
30
56
 
57
+ /** The register's number lookup (the seam's findCertificatesByNumber,
58
+ * the D1 half's CERTIFICATE_NUMBER_SQL's twin): the keyed read against
59
+ * idx_entities_store_certificate_number (migration 0028) — the
60
+ * json_valid-guarded extract's exact spelling, COLLATE NOCASE for the
61
+ * register's case-insensitive number match. INDEXED BY pins the walk:
62
+ * without it the planner prefers idx_entities_store_org for the ORDER
63
+ * BY (org_id, rowid) — the seam's list order, kept so the first match
64
+ * IS the retiring listEntities scan's first match — and the "index"
65
+ * would walk the whole store. */
66
+ const CERTIFICATE_NUMBER_SQL = `SELECT store, id, org_id, data, updated_at
67
+ FROM entities INDEXED BY idx_entities_store_certificate_number
68
+ WHERE store = 'certificates'
69
+ AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE = ?
70
+ ORDER BY org_id, rowid`
71
+
31
72
  export function listEntities(store: string): EntityRow[] {
32
73
  // The ORDER BY is the seam's contract (the 0.2.3 pin, the D1 half's
33
74
  // twin): (org_id, rowid) — the read's observable order since
@@ -45,6 +86,12 @@ export function getEntity(store: string, id: string): EntityRow | undefined {
45
86
  .get(store, id) as EntityRow | undefined
46
87
  }
47
88
 
89
+ /** The register's keyed number lookup — one index walk, never the
90
+ * listEntities scan + per-row JSON parse. */
91
+ export function findCertificatesByNumber(number: string): EntityRow[] {
92
+ return getDb().prepare(CERTIFICATE_NUMBER_SQL).all(number) as EntityRow[]
93
+ }
94
+
48
95
  export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
49
96
  const db = getDb()
50
97
  const write = db.transaction(() => {
@@ -52,6 +99,7 @@ export function putEntity(store: string, id: string, orgId: string | null, data:
52
99
  db.prepare(ENTITY_CHANGE_SQL).run(store, 'persist', id)
53
100
  })
54
101
  write()
102
+ emitJournalAppends([{ store, type: 'persist', id }])
55
103
  }
56
104
 
57
105
  /** The multi-row write (the seam's putEntities, the 2026-09-07 audit's
@@ -74,6 +122,9 @@ export function putEntities(store: string, rows: readonly EntityWriteInput[]): v
74
122
  journal.run(store, 'persist', row.id)
75
123
  }
76
124
  })()
125
+ // The fan-out fires per LANDED chunk (the D1 half's posture) — a
126
+ // failed chunk throws before its emit.
127
+ emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
77
128
  }
78
129
  }
79
130
 
@@ -88,6 +139,7 @@ export function deleteEntity(store: string, id: string): boolean {
88
139
  }
89
140
  })
90
141
  write()
142
+ if (gone) emitJournalAppends([{ store, type: 'remove', id }])
91
143
  return gone
92
144
  }
93
145
 
@@ -9,7 +9,7 @@
9
9
  // ═══════════════════════════════════════════════════════════════════
10
10
 
11
11
  import { getDb } from './store'
12
- import { APPEND_EVENTS_CHUNK, EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type PlatformEvent } from '../../store'
12
+ import { APPEND_EVENTS_CHUNK, EVENTS_BULK_KEY_CHUNK, EVENTS_ID_CHUNK, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type PlatformEvent } from '../../store'
13
13
 
14
14
  interface EventRow {
15
15
  seq: number
@@ -94,6 +94,28 @@ export function getEvent(id: string): PlatformEvent | null {
94
94
  return row ? toPlatformEvent(row) : null
95
95
  }
96
96
 
97
+ /** The BULK by-id read (the notify digest's event join): every id
98
+ * resolves in ONE statement per EVENTS_ID_CHUNK ids — the IN walk
99
+ * against the id UNIQUE index, the same chunking as the D1 half, so
100
+ * the backends stay answer-identical. The answer is INPUT-ALIGNED:
101
+ * position i carries the row for ids[i], null where no event carries
102
+ * the id (the per-id getEvent loop's exact answers; a duplicate id
103
+ * answers its row at every position). An empty list answers []
104
+ * without issuing a statement. */
105
+ export function getEvents(ids: readonly string[]): (PlatformEvent | null)[] {
106
+ if (ids.length === 0) return []
107
+ const byId = new Map<string, PlatformEvent>()
108
+ for (let i = 0; i < ids.length; i += EVENTS_ID_CHUNK) {
109
+ const chunk = ids.slice(i, i + EVENTS_ID_CHUNK)
110
+ const marks = chunk.map(() => '?').join(', ')
111
+ const rows = getDb()
112
+ .prepare(`SELECT * FROM events WHERE id IN (${marks})`)
113
+ .all(...chunk) as EventRow[]
114
+ for (const row of rows) byId.set(row.id, toPlatformEvent(row))
115
+ }
116
+ return ids.map(id => byId.get(id) ?? null)
117
+ }
118
+
97
119
  /** The subscription grammar's SQL resolution: the pinned columns match
98
120
  * by equality; the free legs stay out of the WHERE. The BULK form (the
99
121
  * 2026-09-06 audit's notify-inbox seam) resolves every pinned (domain,
@@ -122,6 +122,15 @@ CREATE INDEX IF NOT EXISTS idx_entities_store_action ON entities (
122
122
  json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id'),
123
123
  json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.timestamp')
124
124
  );
125
+ -- The public register's certificate-number lookup (migration 0028, the
126
+ -- seam's findCertificatesByNumber): (store, certificate_number) out of
127
+ -- the data JSON, COLLATE NOCASE for the register's case-insensitive
128
+ -- number match, so the keyed read is an index walk instead of a
129
+ -- listEntities scan + per-row JSON parse. The same json_valid guard.
130
+ CREATE INDEX IF NOT EXISTS idx_entities_store_certificate_number ON entities (
131
+ store,
132
+ json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE
133
+ );
125
134
 
126
135
  -- The change journal: every write appends (seq, store, type, id) —
127
136
  -- the SSE stream tails it (each client filters its stores).
@@ -83,10 +83,12 @@ import {
83
83
  import {
84
84
  changesAfter,
85
85
  deleteEntity,
86
+ findCertificatesByNumber,
86
87
  getEntity,
87
88
  latestChangeSeq,
88
89
  latestChangeSeqFor,
89
90
  listEntities,
91
+ onJournalAppend,
90
92
  putEntities,
91
93
  putEntity,
92
94
  } from './sqlite/entities'
@@ -96,6 +98,7 @@ import {
96
98
  eventsAfter,
97
99
  eventsMatching,
98
100
  getEvent,
101
+ getEvents,
99
102
  latestEventSeq,
100
103
  } from './sqlite/events'
101
104
  import {
@@ -191,7 +194,7 @@ import {
191
194
  updateOpAccount,
192
195
  updateUserName,
193
196
  } from './sqlite/op-accounts-store'
194
- import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EntityWriteInput, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type InstrumentRegistrationWriteInput, 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 OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
197
+ import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EntityWriteInput, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type InstrumentRegistrationWriteInput, type JournalAppend, 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 OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
195
198
  import {
196
199
  advanceWebauthnCounter,
197
200
  consumeMfaPending,
@@ -1018,6 +1021,9 @@ export function createSqliteServerStore(): ServerStore {
1018
1021
  async getEntity(store: string, id: string): Promise<EntityRow | undefined> {
1019
1022
  return getEntity(store, id)
1020
1023
  },
1024
+ async findCertificatesByNumber(number: string): Promise<EntityRow[]> {
1025
+ return findCertificatesByNumber(number)
1026
+ },
1021
1027
  async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
1022
1028
  putEntity(store, id, orgId, data)
1023
1029
  },
@@ -1036,6 +1042,9 @@ export function createSqliteServerStore(): ServerStore {
1036
1042
  async latestChangeSeqFor(store: string): Promise<number> {
1037
1043
  return latestChangeSeqFor(store)
1038
1044
  },
1045
+ onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void {
1046
+ return onJournalAppend(listener)
1047
+ },
1039
1048
 
1040
1049
  // ── the platform event store (TODO.notify/01) ──
1041
1050
  async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
@@ -1053,6 +1062,9 @@ export function createSqliteServerStore(): ServerStore {
1053
1062
  async getEvent(id: string): Promise<PlatformEvent | null> {
1054
1063
  return getEvent(id)
1055
1064
  },
1065
+ async getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]> {
1066
+ return getEvents(ids)
1067
+ },
1056
1068
  async eventsMatching(filter: EventKeyFilter | { keys: readonly EventEntityKey[] }, limit = 500): Promise<PlatformEvent[]> {
1057
1069
  return eventsMatching(filter, limit)
1058
1070
  },
package/src/store.ts CHANGED
@@ -175,6 +175,16 @@ export interface EntityChange {
175
175
  at: string
176
176
  }
177
177
 
178
+ /** The journal fan-out's payload (onJournalAppend): the (store, type,
179
+ * id) triple one entity_changes row carries. seq/at are the
180
+ * database's (the write path never reads them back) — the consumer
181
+ * re-reads via changesAfter from its own cursor. */
182
+ export interface JournalAppend {
183
+ store: string
184
+ type: 'persist' | 'remove'
185
+ id: string
186
+ }
187
+
178
188
  /** One row of the multi-row write (putEntities): putEntity's (id,
179
189
  * orgId, data) legs — the store is the call's, once for the whole
180
190
  * batch. */
@@ -265,6 +275,14 @@ export interface EventEntityKey {
265
275
  * backends stay answer-identical. */
266
276
  export const EVENTS_BULK_KEY_CHUNK = 49
267
277
 
278
+ /** The bulk by-id read's statement chunk (getEvents): D1 caps a
279
+ * statement at 100 bound parameters and the IN list binds ONE per id
280
+ * (no limit parameter — the answer size is the matched-id count), so
281
+ * a bulk call issues ceil(ids / 99) statements — ONE for any
282
+ * realistic digest window — never one per id. The SQLite half chunks
283
+ * identically so the two backends stay answer-identical. */
284
+ export const EVENTS_ID_CHUNK = 99
285
+
268
286
  /** The bulk append's chunk, in ROWS (appendEvents): each event
269
287
  * contributes ONE statement (the INSERT … RETURNING * answers the
270
288
  * stored row off the write itself, the appendEvent halving), so a
@@ -2470,6 +2488,22 @@ export interface ServerStore {
2470
2488
  * migration 0023's expression index flipped the unnamed walk). */
2471
2489
  listEntities(store: string): Promise<EntityRow[]>
2472
2490
  getEntity(store: string, id: string): Promise<EntityRow | undefined>
2491
+ /** The public register's certificate-number lookup (the verify
2492
+ * surfaces' keyed read — the 2026-09-07 performance audit's
2493
+ * public-lookup seam): the register resolved a number by
2494
+ * full-scanning listEntities('certificates') and JSON-parsing every
2495
+ * row to case-fold certificate_number — O(store rows) per lookup.
2496
+ * The keyed read walks idx_entities_store_certificate_number
2497
+ * (migration 0028): the json_valid-guarded extract, COLLATE NOCASE
2498
+ * (the register's number match is case-insensitive; the fold is
2499
+ * ASCII, the number grammar's alphabet). Answers every match in the
2500
+ * seam's list order (org_id, rowid), so the first match IS the
2501
+ * retiring scan's first match. The statement INDEXED BY-pins the
2502
+ * walk: a database behind migration 0028 errors honestly, never
2503
+ * scans silently. The hardcoded store name follows the
2504
+ * lastAccountSignIns 'auditEvents' precedent — the read's semantics
2505
+ * ARE the consumer's data convention. */
2506
+ findCertificatesByNumber(number: string): Promise<EntityRow[]>
2473
2507
  putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
2474
2508
  /** The MULTI-ROW write (the 2026-09-07 performance audit's J1 — the
2475
2509
  * CSV registration commit awaited putEntity per imported row, ~10⁴
@@ -2516,6 +2550,21 @@ export interface ServerStore {
2516
2550
  * audit's G1: the smart side's bootstrap composite ETag switches
2517
2551
  * from the global seq to the per-set composite of these. */
2518
2552
  latestChangeSeqFor(store: string): Promise<number>
2553
+ /** The journal fan-out (the SSE stream's true-push wake): registers a
2554
+ * listener on the ISOLATE-scope registry — every entity_changes
2555
+ * append landing in THIS isolate (putEntity's entry, each putEntities
2556
+ * chunk's entries as the chunk lands, deleteEntity's remove) fires it
2557
+ * synchronously AFTER the write stands, with the appended (store,
2558
+ * type, id) triples in the write's input order. The answer is the
2559
+ * unregister (idempotent).
2560
+ *
2561
+ * The honesty boundary: the journal's source of truth is the
2562
+ * DATABASE — a write landing in ANOTHER isolate (a sibling Worker)
2563
+ * never fires this isolate's listeners, so a consumer keeps its poll
2564
+ * as the fallback and treats the fan-out as a wake-up hint, never a
2565
+ * correctness channel. A listener's throw is swallowed per listener:
2566
+ * the write path never breaks for a listener. */
2567
+ onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void
2519
2568
 
2520
2569
  // ── the platform event store (TODO.notify/01) ──
2521
2570
  /** Append one declared event (the emitter's write; one row inside the
@@ -2545,6 +2594,17 @@ export interface ServerStore {
2545
2594
  /** The by-id read (the inbox state write's guard — TODO.notify/03: a
2546
2595
  * marker lands only on an event that exists and is the caller's). */
2547
2596
  getEvent(id: string): Promise<PlatformEvent | null>
2597
+ /** The BULK by-id read (the notify digest's event join): the digest
2598
+ * run awaited getEvent once per queued delivery row — N serial round
2599
+ * trips for N rows. The bulk form resolves every id in ONE statement
2600
+ * per chunk of EVENTS_ID_CHUNK ids (the IN walk against the id UNIQUE
2601
+ * index; never a compound SELECT — the D1 5-term cap rule).
2602
+ *
2603
+ * The answer contract: INPUT-ALIGNED — position i answers the row for
2604
+ * ids[i], null where no event carries the id (exactly the per-id
2605
+ * getEvent loop's answers; a duplicate id answers its row at every
2606
+ * position). An empty list answers [] without issuing a statement. */
2607
+ getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]>
2548
2608
  /** The subscription grammar's SQL resolution: the columns the pattern
2549
2609
  * pins, equality-matched (`WHERE domain = ? AND entity_id = ?` — the
2550
2610
  * column split's whole point, never a string scan on a composed key). */