@oimlsmart/platform-server 0.2.9 → 0.2.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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
@@ -30,8 +30,10 @@
30
30
 
31
31
  import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
32
32
  import {
33
+ APPEND_EVENTS_CHUNK,
33
34
  DEMO_PASSWORD,
34
35
  EVENTS_BULK_KEY_CHUNK,
36
+ INSTRUMENT_REGISTRATIONS_CHUNK,
35
37
  PUT_ENTITIES_CHUNK,
36
38
  StoreUnavailable,
37
39
  type AccountEmail,
@@ -48,6 +50,7 @@ import {
48
50
  type EntityWriteInput,
49
51
  type EventEntityKey,
50
52
  type EventKeyFilter,
53
+ type EventWriteInput,
51
54
  type FederationPeer,
52
55
  type IdentityApproval,
53
56
  type IdentityLink,
@@ -84,6 +87,7 @@ import {
84
87
  type InstrumentRegistration,
85
88
  type InstrumentRegistrationLifecycle,
86
89
  type InstrumentRegistrationScopeStatus,
90
+ type InstrumentRegistrationWriteInput,
87
91
  type PersonalAccessToken,
88
92
  type PlatformEvent,
89
93
  resolveOrgContext,
@@ -423,6 +427,20 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
423
427
  ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
424
428
  const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
425
429
 
430
+ /** The register write's INSERT — ONE textual source for
431
+ * createInstrumentRegistration and createInstrumentRegistrations alike
432
+ * (the batch verb appends RETURNING * so the stored row answers off
433
+ * the write itself; the single verb keeps its .run() + by-id
434
+ * read-back pair). */
435
+ const INSTRUMENT_REGISTRATION_INSERT_SQL = `INSERT OR IGNORE INTO instrument_registrations
436
+ (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
437
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
438
+
439
+ /** The event append's INSERT … RETURNING * — ONE textual source for
440
+ * appendEvent and appendEvents alike (the stored row — seq + the
441
+ * default at — answers off the write itself). */
442
+ const EVENT_INSERT_SQL = 'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *'
443
+
426
444
  export class D1ServerStore implements ServerStore {
427
445
  /** The RAW binding — the ensure memos (and d1StoreFor's map) key on
428
446
  * it: the facade below is per-instance and would never hit. */
@@ -1456,6 +1474,16 @@ export class D1ServerStore implements ServerStore {
1456
1474
  return (res.meta.changes ?? 0) > 0
1457
1475
  }
1458
1476
 
1477
+ /** The governance view's population read: the client's LIVE access-token
1478
+ * count (unexpired — the row's absence IS the revocation). */
1479
+ async countOidcAccessTokensForClient(clientId: string): Promise<number> {
1480
+ await this.ensureMembershipSupport()
1481
+ const row = await this.stmt(
1482
+ "SELECT COUNT(*) AS n FROM oidc_access_tokens WHERE client_id = ? AND datetime(expires_at) > datetime('now')", clientId,
1483
+ ).first<{ n: number }>()
1484
+ return row?.n ?? 0
1485
+ }
1486
+
1459
1487
  private static toRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
1460
1488
  return {
1461
1489
  token: row.token as string,
@@ -1546,6 +1574,18 @@ export class D1ServerStore implements ServerStore {
1546
1574
  return res.meta.changes ?? 0
1547
1575
  }
1548
1576
 
1577
+ /** The governance view's population read: the client's LIVE refresh-token
1578
+ * count (unconsumed AND unexpired — a revoked family is deleted
1579
+ * wholesale, so a live row's presence is the offline grant's standing). */
1580
+ async countOidcRefreshTokensForClient(clientId: string): Promise<number> {
1581
+ await this.ensureMembershipSupport()
1582
+ await this.ensureOidcRefreshTokenSupport()
1583
+ const row = await this.stmt(
1584
+ "SELECT COUNT(*) AS n FROM oidc_refresh_tokens WHERE client_id = ? AND consumed_at IS NULL AND datetime(expires_at) > datetime('now')", clientId,
1585
+ ).first<{ n: number }>()
1586
+ return row?.n ?? 0
1587
+ }
1588
+
1549
1589
  async listOidcKeys(): Promise<OidcKeyRow[]> {
1550
1590
  const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
1551
1591
  return res.results.map(row => ({
@@ -1639,6 +1679,17 @@ export class D1ServerStore implements ServerStore {
1639
1679
  return (res.meta.changes ?? 0) > 0
1640
1680
  }
1641
1681
 
1682
+ /** The client-registry governance view's per-client read: EVERY grant row
1683
+ * the client holds — live AND revoked, newest first. */
1684
+ async listOidcConsentGrantsForClient(clientId: string): Promise<OidcConsentGrant[]> {
1685
+ await this.ensureConsentGrantSupport()
1686
+ const res = await this.stmt(
1687
+ 'SELECT * FROM oidc_consent_grants WHERE client_id = ? ORDER BY created_at DESC, rowid DESC',
1688
+ clientId,
1689
+ ).all<Record<string, unknown>>()
1690
+ return res.results.map(D1ServerStore.toConsentGrant)
1691
+ }
1692
+
1642
1693
  // ── the upstream providers (TODO.identity/08) ─────────────────────
1643
1694
  // The provider + link rows port directly (D1 is SQLite).
1644
1695
 
@@ -3390,23 +3441,10 @@ export class D1ServerStore implements ServerStore {
3390
3441
 
3391
3442
  /** Register the instrument; NULL on the (certificate, serial)
3392
3443
  * conflict (the route's honest 409). */
3393
- async createInstrumentRegistration(input: {
3394
- id: string
3395
- certificateId: string
3396
- holderOrgId: string
3397
- standardId: string
3398
- serialNumber: string
3399
- manufactureDate?: string | null
3400
- designations?: Record<string, unknown>
3401
- scopeStatus: InstrumentRegistrationScopeStatus
3402
- scopeDetail?: string | null
3403
- registeredBy?: string | null
3404
- }): Promise<InstrumentRegistration | null> {
3444
+ async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
3405
3445
  await this.ensureInstrumentRegistrationSupport()
3406
3446
  const res = await this.stmt(
3407
- `INSERT OR IGNORE INTO instrument_registrations
3408
- (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
3409
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3447
+ INSTRUMENT_REGISTRATION_INSERT_SQL,
3410
3448
  input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
3411
3449
  input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
3412
3450
  input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
@@ -3415,6 +3453,37 @@ export class D1ServerStore implements ServerStore {
3415
3453
  return this.getInstrumentRegistration(input.id)
3416
3454
  }
3417
3455
 
3456
+ /** The batch register write (the seam's contract, mechanically):
3457
+ * each row's INSERT OR IGNORE … RETURNING * rides ONE db.batch per
3458
+ * INSTRUMENT_REGISTRATIONS_CHUNK rows, the statements in INPUT order,
3459
+ * the chunks SERIALLY — the register's insertion order IS the CSV's
3460
+ * row order. The per-row answer comes off the batch's own results:
3461
+ * the stored row, or NULL when the RETURNING came back empty (the
3462
+ * (certificate, serial) conflict — the single verb's honest null).
3463
+ * The chunk is the atomic unit: a D1 batch lands all-or-nothing; a
3464
+ * failed chunk throws with its rows unlanded, earlier chunks
3465
+ * standing, later chunks never issued. */
3466
+ async createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]> {
3467
+ await this.ensureInstrumentRegistrationSupport()
3468
+ if (rows.length === 0) return []
3469
+ const out: (InstrumentRegistration | null)[] = []
3470
+ for (let i = 0; i < rows.length; i += INSTRUMENT_REGISTRATIONS_CHUNK) {
3471
+ const chunk = rows.slice(i, i + INSTRUMENT_REGISTRATIONS_CHUNK)
3472
+ const statements = chunk.map(row => this.stmt(
3473
+ `${INSTRUMENT_REGISTRATION_INSERT_SQL} RETURNING *`,
3474
+ row.id, row.certificateId, row.holderOrgId, row.standardId, row.serialNumber,
3475
+ row.manufactureDate ?? null, JSON.stringify(row.designations ?? {}),
3476
+ row.scopeStatus, row.scopeDetail ?? null, row.registeredBy ?? null,
3477
+ ))
3478
+ const results = await this.db.batch(statements)
3479
+ for (const res of results) {
3480
+ const stored = res.results[0] as Record<string, unknown> | undefined
3481
+ out.push(stored ? D1ServerStore.toInstrumentRegistration(stored) : null)
3482
+ }
3483
+ }
3484
+ return out
3485
+ }
3486
+
3418
3487
  /** The lifecycle act (the transition rule is the route's); stamps
3419
3488
  * updated_at/by. NULL when the register does not carry the id. */
3420
3489
  async setInstrumentRegistrationLifecycle(
@@ -3535,24 +3604,42 @@ export class D1ServerStore implements ServerStore {
3535
3604
  }
3536
3605
  }
3537
3606
 
3538
- async appendEvent(input: {
3539
- id: string
3540
- domain: string
3541
- entityId: string
3542
- action: string
3543
- payload: string
3544
- }): Promise<PlatformEvent> {
3607
+ async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
3545
3608
  // ONE round trip: the RETURNING clause (SQLite ≥ 3.35, D1 included)
3546
3609
  // answers the stored row — seq + the default at — off the INSERT
3547
3610
  // itself; the SELECT-by-id read-back retired (the 2026-09-06 audit:
3548
3611
  // two round trips per event, halved).
3549
3612
  const row = await this.stmt(
3550
- 'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *',
3613
+ EVENT_INSERT_SQL,
3551
3614
  input.id, input.domain, input.entityId, input.action, input.payload,
3552
3615
  ).first<Record<string, unknown>>()
3553
3616
  return D1ServerStore.toPlatformEvent(row!)
3554
3617
  }
3555
3618
 
3619
+ /** The bulk append (the seam's contract, mechanically): each event's
3620
+ * INSERT … RETURNING * rides ONE db.batch per APPEND_EVENTS_CHUNK
3621
+ * rows, the statements in INPUT order, the chunks SERIALLY — the
3622
+ * events' seq order IS the input order. The answer is the stored
3623
+ * rows, input-aligned. The chunk is the atomic unit: a D1 batch
3624
+ * lands all-or-nothing; a failed chunk throws with its events
3625
+ * unlanded, earlier chunks standing, later chunks never issued. */
3626
+ async appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]> {
3627
+ if (events.length === 0) return []
3628
+ const out: PlatformEvent[] = []
3629
+ for (let i = 0; i < events.length; i += APPEND_EVENTS_CHUNK) {
3630
+ const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
3631
+ const statements = chunk.map(e => this.stmt(
3632
+ EVENT_INSERT_SQL,
3633
+ e.id, e.domain, e.entityId, e.action, e.payload,
3634
+ ))
3635
+ const results = await this.db.batch(statements)
3636
+ for (const res of results) {
3637
+ out.push(D1ServerStore.toPlatformEvent(res.results[0] as Record<string, unknown>))
3638
+ }
3639
+ }
3640
+ return out
3641
+ }
3642
+
3556
3643
  async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
3557
3644
  const res = await this.stmt(
3558
3645
  'SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
@@ -77,6 +77,15 @@ export function listConsentGrants(userId: string): OidcConsentGrant[] {
77
77
  return rows.map(toConsentGrant)
78
78
  }
79
79
 
80
+ /** The client-registry governance view's per-client read: EVERY grant row
81
+ * the client holds — live AND revoked, newest first. */
82
+ export function listOidcConsentGrantsForClient(clientId: string): OidcConsentGrant[] {
83
+ const rows = getDb().prepare(
84
+ 'SELECT * FROM oidc_consent_grants WHERE client_id = ? ORDER BY created_at DESC, rowid DESC',
85
+ ).all(clientId) as Array<Record<string, unknown>>
86
+ return rows.map(toConsentGrant)
87
+ }
88
+
80
89
  /** The guarded revoke: the owner's LIVE row flips, once. */
81
90
  export function revokeConsentGrant(id: string, userId: string): boolean {
82
91
  return getDb().prepare(
@@ -9,7 +9,7 @@
9
9
  // ═══════════════════════════════════════════════════════════════════
10
10
 
11
11
  import { getDb } from './store'
12
- import { EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type PlatformEvent } from '../../store'
12
+ import { APPEND_EVENTS_CHUNK, EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type PlatformEvent } from '../../store'
13
13
 
14
14
  interface EventRow {
15
15
  seq: number
@@ -33,13 +33,7 @@ function toPlatformEvent(row: EventRow): PlatformEvent {
33
33
  }
34
34
  }
35
35
 
36
- export function appendEvent(input: {
37
- id: string
38
- domain: string
39
- entityId: string
40
- action: string
41
- payload: string
42
- }): PlatformEvent {
36
+ export function appendEvent(input: EventWriteInput): PlatformEvent {
43
37
  // ONE statement: RETURNING answers the stored row (seq + the default
44
38
  // at) off the INSERT itself — the same halving as the D1 half.
45
39
  return toPlatformEvent(
@@ -49,6 +43,33 @@ export function appendEvent(input: {
49
43
  )
50
44
  }
51
45
 
46
+ /** The bulk append (the seam's appendEvents, the 2026-09-07 audit's
47
+ * chain half): each event lands exactly as appendEvent would land it —
48
+ * the INSERT … RETURNING * answers the stored row off the write itself —
49
+ * the answer rows in INPUT order (their seqs strictly increase in it),
50
+ * one transaction per APPEND_EVENTS_CHUNK rows. The chunk is the atomic
51
+ * unit, matching the D1 batch's all-or-nothing; a failed chunk throws
52
+ * with its events unlanded, earlier chunks standing, later chunks never
53
+ * issued. The chunks run serially, so the events' seq order IS the
54
+ * input order. */
55
+ export function appendEvents(events: readonly EventWriteInput[]): PlatformEvent[] {
56
+ if (events.length === 0) return []
57
+ const db = getDb()
58
+ const insert = db.prepare(
59
+ `INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *`,
60
+ )
61
+ const out: PlatformEvent[] = []
62
+ for (let i = 0; i < events.length; i += APPEND_EVENTS_CHUNK) {
63
+ const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
64
+ db.transaction(() => {
65
+ for (const e of chunk) {
66
+ out.push(toPlatformEvent(insert.get(e.id, e.domain, e.entityId, e.action, e.payload) as EventRow))
67
+ }
68
+ })()
69
+ }
70
+ return out
71
+ }
72
+
52
73
  /** The feed's raw leg: events past the cursor, seq-ordered. */
53
74
  export function eventsAfter(seq: number, limit = 500): PlatformEvent[] {
54
75
  const rows = getDb()
@@ -275,6 +275,15 @@ export function deleteOidcAccessToken(token: string, clientId: string): boolean
275
275
  return res.changes > 0
276
276
  }
277
277
 
278
+ /** The governance view's population read: the client's LIVE access-token
279
+ * count (unexpired — the row's absence IS the revocation). */
280
+ export function countOidcAccessTokensForClient(clientId: string): number {
281
+ const row = getDb().prepare(
282
+ "SELECT COUNT(*) AS n FROM oidc_access_tokens WHERE client_id = ? AND datetime(expires_at) > datetime('now')",
283
+ ).get(clientId) as { n: number }
284
+ return row.n
285
+ }
286
+
278
287
  function toOidcRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
279
288
  return {
280
289
  token: row.token as string,
@@ -354,6 +363,16 @@ export function deleteOidcRefreshTokensForUserClient(userId: string, clientId: s
354
363
  return getDb().prepare('DELETE FROM oidc_refresh_tokens WHERE user_id = ? AND client_id = ?').run(userId, clientId).changes
355
364
  }
356
365
 
366
+ /** The governance view's population read: the client's LIVE refresh-token
367
+ * count (unconsumed AND unexpired — a revoked family is deleted wholesale,
368
+ * so a live row's presence is the offline grant's standing). */
369
+ export function countOidcRefreshTokensForClient(clientId: string): number {
370
+ const row = getDb().prepare(
371
+ "SELECT COUNT(*) AS n FROM oidc_refresh_tokens WHERE client_id = ? AND consumed_at IS NULL AND datetime(expires_at) > datetime('now')",
372
+ ).get(clientId) as { n: number }
373
+ return row.n
374
+ }
375
+
357
376
  /** The amr column's honest parse (a JSON array of strings, else null —
358
377
  * the provenance is absent on rows that predate the wave). */
359
378
  function parseJsonStringList(raw: unknown): string[] | null {
@@ -1355,7 +1355,9 @@ import type {
1355
1355
  InstrumentRegistration,
1356
1356
  InstrumentRegistrationLifecycle,
1357
1357
  InstrumentRegistrationScopeStatus,
1358
+ InstrumentRegistrationWriteInput,
1358
1359
  } from '../../store'
1360
+ import { INSTRUMENT_REGISTRATIONS_CHUNK } from '../../store'
1359
1361
 
1360
1362
  interface InstrumentRegistrationRow {
1361
1363
  id: string
@@ -1430,18 +1432,7 @@ export function getInstrumentRegistration(id: string): InstrumentRegistration |
1430
1432
  /** Register the instrument; NULL on the (certificate, serial) conflict
1431
1433
  * (the same physical unit never registers twice under one certificate —
1432
1434
  * the route's honest 409). */
1433
- export function createInstrumentRegistration(input: {
1434
- id: string
1435
- certificateId: string
1436
- holderOrgId: string
1437
- standardId: string
1438
- serialNumber: string
1439
- manufactureDate?: string | null
1440
- designations?: Record<string, unknown>
1441
- scopeStatus: InstrumentRegistrationScopeStatus
1442
- scopeDetail?: string | null
1443
- registeredBy?: string | null
1444
- }): InstrumentRegistration | null {
1435
+ export function createInstrumentRegistration(input: InstrumentRegistrationWriteInput): InstrumentRegistration | null {
1445
1436
  const res = getDb().prepare(
1446
1437
  `INSERT OR IGNORE INTO instrument_registrations
1447
1438
  (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
@@ -1455,6 +1446,42 @@ export function createInstrumentRegistration(input: {
1455
1446
  return getInstrumentRegistration(input.id)
1456
1447
  }
1457
1448
 
1449
+ /** The batch register write (the seam's createInstrumentRegistrations,
1450
+ * the 2026-09-07 audit's REAL J1): each row lands exactly as
1451
+ * createInstrumentRegistration would land it — the INSERT OR IGNORE …
1452
+ * RETURNING * answers the stored row off the write itself (NULL when it
1453
+ * comes back empty: the (certificate, serial) conflict), the per-row
1454
+ * answers aligned with the INPUT order — one transaction per
1455
+ * INSTRUMENT_REGISTRATIONS_CHUNK rows. The chunk is the atomic unit,
1456
+ * matching the D1 batch's all-or-nothing; a failed chunk throws with
1457
+ * its rows unlanded, earlier chunks standing, later chunks never
1458
+ * issued. The chunks run serially, so the register's insertion order
1459
+ * IS the input's row order. */
1460
+ export function createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): (InstrumentRegistration | null)[] {
1461
+ if (rows.length === 0) return []
1462
+ const db = getDb()
1463
+ const insert = db.prepare(
1464
+ `INSERT OR IGNORE INTO instrument_registrations
1465
+ (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
1466
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1467
+ )
1468
+ const out: (InstrumentRegistration | null)[] = []
1469
+ for (let i = 0; i < rows.length; i += INSTRUMENT_REGISTRATIONS_CHUNK) {
1470
+ const chunk = rows.slice(i, i + INSTRUMENT_REGISTRATIONS_CHUNK)
1471
+ db.transaction(() => {
1472
+ for (const row of chunk) {
1473
+ const stored = insert.get(
1474
+ row.id, row.certificateId, row.holderOrgId, row.standardId, row.serialNumber,
1475
+ row.manufactureDate ?? null, JSON.stringify(row.designations ?? {}),
1476
+ row.scopeStatus, row.scopeDetail ?? null, row.registeredBy ?? null,
1477
+ ) as InstrumentRegistrationRow | undefined
1478
+ out.push(stored ? instrumentRegistrationPayload(stored) : null)
1479
+ }
1480
+ })()
1481
+ }
1482
+ return out
1483
+ }
1484
+
1458
1485
  /** The lifecycle act (the transition rule is the route's); stamps
1459
1486
  * updated_at/by. NULL when the register does not carry the id. */
1460
1487
  export function setInstrumentRegistrationLifecycle(
@@ -22,6 +22,7 @@ import {
22
22
  createOrgMembership,
23
23
  createOrgRegistryOrg,
24
24
  createInstrumentRegistration,
25
+ createInstrumentRegistrations,
25
26
  createSession,
26
27
  decideCertificateHolderClaim,
27
28
  decideIdentityApproval,
@@ -91,6 +92,7 @@ import {
91
92
  } from './sqlite/entities'
92
93
  import {
93
94
  appendEvent,
95
+ appendEvents,
94
96
  eventsAfter,
95
97
  eventsMatching,
96
98
  getEvent,
@@ -120,6 +122,8 @@ import {
120
122
  import {
121
123
  consumeOidcCode,
122
124
  consumeOidcRefreshToken,
125
+ countOidcAccessTokensForClient,
126
+ countOidcRefreshTokensForClient,
123
127
  createOidcAccessToken,
124
128
  createOidcAuthorization,
125
129
  createOidcCode,
@@ -187,7 +191,7 @@ import {
187
191
  updateOpAccount,
188
192
  updateUserName,
189
193
  } from './sqlite/op-accounts-store'
190
- 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 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 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'
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'
191
195
  import {
192
196
  advanceWebauthnCounter,
193
197
  consumeMfaPending,
@@ -223,6 +227,7 @@ import {
223
227
  import {
224
228
  getConsentGrant,
225
229
  listConsentGrants,
230
+ listOidcConsentGrantsForClient,
226
231
  recordConsentGrant,
227
232
  revokeConsentGrant,
228
233
  } from './sqlite/consent-grants-store'
@@ -568,20 +573,12 @@ export function createSqliteServerStore(): ServerStore {
568
573
  async getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null> {
569
574
  return getInstrumentRegistration(id)
570
575
  },
571
- async createInstrumentRegistration(input: {
572
- id: string
573
- certificateId: string
574
- holderOrgId: string
575
- standardId: string
576
- serialNumber: string
577
- manufactureDate?: string | null
578
- designations?: Record<string, unknown>
579
- scopeStatus: InstrumentRegistrationScopeStatus
580
- scopeDetail?: string | null
581
- registeredBy?: string | null
582
- }): Promise<InstrumentRegistration | null> {
576
+ async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
583
577
  return createInstrumentRegistration(input)
584
578
  },
579
+ async createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]> {
580
+ return createInstrumentRegistrations(rows)
581
+ },
585
582
  async setInstrumentRegistrationLifecycle(
586
583
  id: string,
587
584
  lifecycle: InstrumentRegistrationLifecycle,
@@ -672,6 +669,9 @@ export function createSqliteServerStore(): ServerStore {
672
669
  async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
673
670
  return deleteOidcAccessToken(token, clientId)
674
671
  },
672
+ async countOidcAccessTokensForClient(clientId: string): Promise<number> {
673
+ return countOidcAccessTokensForClient(clientId)
674
+ },
675
675
  async createOidcRefreshToken(input: {
676
676
  token: string
677
677
  userId: string
@@ -694,6 +694,9 @@ export function createSqliteServerStore(): ServerStore {
694
694
  async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
695
695
  return deleteOidcRefreshTokensForUserClient(userId, clientId)
696
696
  },
697
+ async countOidcRefreshTokensForClient(clientId: string): Promise<number> {
698
+ return countOidcRefreshTokensForClient(clientId)
699
+ },
697
700
  async listOidcKeys(): Promise<OidcKeyRow[]> {
698
701
  return listOidcKeys()
699
702
  },
@@ -717,6 +720,9 @@ export function createSqliteServerStore(): ServerStore {
717
720
  async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
718
721
  return revokeConsentGrant(id, userId)
719
722
  },
723
+ async listOidcConsentGrantsForClient(clientId: string): Promise<OidcConsentGrant[]> {
724
+ return listOidcConsentGrantsForClient(clientId)
725
+ },
720
726
 
721
727
  // ── the upstream providers (TODO.identity/08) ──
722
728
  async listIdentityProviders(): Promise<IdentityProvider[]> {
@@ -1032,15 +1038,12 @@ export function createSqliteServerStore(): ServerStore {
1032
1038
  },
1033
1039
 
1034
1040
  // ── the platform event store (TODO.notify/01) ──
1035
- async appendEvent(input: {
1036
- id: string
1037
- domain: string
1038
- entityId: string
1039
- action: string
1040
- payload: string
1041
- }): Promise<PlatformEvent> {
1041
+ async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
1042
1042
  return appendEvent(input)
1043
1043
  },
1044
+ async appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]> {
1045
+ return appendEvents(events)
1046
+ },
1044
1047
  async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
1045
1048
  return eventsAfter(seq, limit)
1046
1049
  },
package/src/store.ts CHANGED
@@ -217,6 +217,17 @@ export interface PlatformEvent {
217
217
  at: string
218
218
  }
219
219
 
220
+ /** One row of the bulk append (appendEvents): appendEvent's legs — the
221
+ * same fields, so each event lands exactly as the single-row verb
222
+ * would land it (the EntityWriteInput pattern). */
223
+ export interface EventWriteInput {
224
+ id: string
225
+ domain: string
226
+ entityId: string
227
+ action: string
228
+ payload: string
229
+ }
230
+
220
231
  /** The key pattern's SQL legs: a subscription pattern (`application/**`,
221
232
  * `certificate` + `issued` across the domain, one entity's
222
233
  * `test-run/asg-…-001/**`) compiles to the columns it pins; absent legs
@@ -243,6 +254,17 @@ export interface EventEntityKey {
243
254
  * backends stay answer-identical. */
244
255
  export const EVENTS_BULK_KEY_CHUNK = 49
245
256
 
257
+ /** The bulk append's chunk, in ROWS (appendEvents): each event
258
+ * contributes ONE statement (the INSERT … RETURNING * answers the
259
+ * stored row off the write itself, the appendEvent halving), so a
260
+ * chunk is one db.batch of APPEND_EVENTS_CHUNK statements — the
261
+ * INSTRUMENT_REGISTRATIONS_CHUNK order. The chunk bounds the ATOMIC
262
+ * unit (a D1 batch is all-or-nothing; the SQLite half's per-chunk
263
+ * transaction matches it). Chunks issue SERIALLY, in input order —
264
+ * the events' seq order IS the input order, and parallel chunks would
265
+ * forfeit it. */
266
+ export const APPEND_EVENTS_CHUNK = 50
267
+
246
268
  // ── the notification subscriptions store (TODO.notify/02) ────────────
247
269
 
248
270
  /** The rule row's mode: 'subscribe' adds the user to the candidates of
@@ -1339,6 +1361,35 @@ export interface InstrumentRegistration {
1339
1361
  updatedBy: string | null
1340
1362
  }
1341
1363
 
1364
+ /** One row of the batch register write (createInstrumentRegistrations):
1365
+ * createInstrumentRegistration's legs — the same fields, so each row
1366
+ * lands exactly as the single-row verb would land it (the
1367
+ * EntityWriteInput pattern). */
1368
+ export interface InstrumentRegistrationWriteInput {
1369
+ id: string
1370
+ certificateId: string
1371
+ holderOrgId: string
1372
+ standardId: string
1373
+ serialNumber: string
1374
+ manufactureDate?: string | null
1375
+ designations?: Record<string, unknown>
1376
+ scopeStatus: InstrumentRegistrationScopeStatus
1377
+ scopeDetail?: string | null
1378
+ registeredBy?: string | null
1379
+ }
1380
+
1381
+ /** The batch register write's chunk, in ROWS (createInstrumentRegistrations):
1382
+ * each row contributes ONE statement (the INSERT OR IGNORE … RETURNING *
1383
+ * answers the stored row off the write itself), so a chunk is one
1384
+ * db.batch of INSTRUMENT_REGISTRATIONS_CHUNK statements — half the
1385
+ * PUT_ENTITIES_CHUNK statement load, the same conservative order. The
1386
+ * chunk bounds the ATOMIC unit (a D1 batch is all-or-nothing; the
1387
+ * SQLite half's per-chunk transaction matches it). Chunks issue
1388
+ * SERIALLY, in input order — the register's insertion order IS the
1389
+ * input's row order (the CSV commit's chain events sequence after it,
1390
+ * same order), and parallel chunks would forfeit it. */
1391
+ export const INSTRUMENT_REGISTRATIONS_CHUNK = 50
1392
+
1342
1393
  /** Per-store org fields for the READ visibility (the multi-party
1343
1394
  * model: a row is visible when ANY named field equals the user's
1344
1395
  * org). The field names are the entities' REAL ones (verified against
@@ -1684,6 +1735,12 @@ export interface ServerStore {
1684
1735
  * client answers false). An absent row answers false too (the endpoint
1685
1736
  * masks both behind its 200). */
1686
1737
  deleteOidcAccessToken(token: string, clientId: string): Promise<boolean>
1738
+ /** The client-registry governance view's population read: the count of
1739
+ * the client's LIVE access tokens (unexpired — the row's absence IS the
1740
+ * revocation, so presence + liveness is standing). A count, never the
1741
+ * rows: the governance console answers "how many sessions stand behind
1742
+ * this client", never a token value. */
1743
+ countOidcAccessTokensForClient(clientId: string): Promise<number>
1687
1744
  /** The refresh tokens (migration 0025 — the SSO wave-C token surface).
1688
1745
  * The mint: token is the opaque value the route generated, familyId the
1689
1746
  * rotation lineage (a fresh id at the code exchange's first mint, the
@@ -1717,6 +1774,12 @@ export interface ServerStore {
1717
1774
  * (account, client) pair goes — the "Revoke access" act ends the
1718
1775
  * offline half with the remembered consent. Answers the count. */
1719
1776
  deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number>
1777
+ /** The client-registry governance view's population read: the count of
1778
+ * the client's LIVE refresh tokens (unconsumed AND unexpired — a
1779
+ * revoked family is DELETED wholesale, so a live row's presence is the
1780
+ * grant's standing). A count, never the rows: the console answers "how
1781
+ * many offline grants stand", never a token value. */
1782
+ countOidcRefreshTokensForClient(clientId: string): Promise<number>
1720
1783
  /** The key rotation history (public halves). */
1721
1784
  listOidcKeys(): Promise<OidcKeyRow[]>
1722
1785
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
@@ -1743,6 +1806,13 @@ export interface ServerStore {
1743
1806
  * live row — a second revoke or another account's row answers false
1744
1807
  * (the PAT guard's posture). The row STAYS. */
1745
1808
  revokeConsentGrant(id: string, userId: string): Promise<boolean>
1809
+ /** The client-registry governance view's per-client read: EVERY grant
1810
+ * row the client holds — live AND revoked (the account console's
1811
+ * listConsentGrants hides the revoked half; the governance view shows
1812
+ * the history the audit chain would otherwise carry alone), newest
1813
+ * first. Read-only: the acts stay on the account console and the
1814
+ * dashboard's revoke routes. */
1815
+ listOidcConsentGrantsForClient(clientId: string): Promise<OidcConsentGrant[]>
1746
1816
 
1747
1817
  // ── the upstream providers (TODO.identity/08) ──
1748
1818
  /** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
@@ -2338,18 +2408,35 @@ export interface ServerStore {
2338
2408
  /** Register the instrument. NULL on the (certificate_id,
2339
2409
  * serial_number) conflict — the same physical unit never registers
2340
2410
  * twice under one certificate (the route's honest 409). */
2341
- createInstrumentRegistration(input: {
2342
- id: string
2343
- certificateId: string
2344
- holderOrgId: string
2345
- standardId: string
2346
- serialNumber: string
2347
- manufactureDate?: string | null
2348
- designations?: Record<string, unknown>
2349
- scopeStatus: InstrumentRegistrationScopeStatus
2350
- scopeDetail?: string | null
2351
- registeredBy?: string | null
2352
- }): Promise<InstrumentRegistration | null>
2411
+ createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null>
2412
+ /** The BATCH register write (the 2026-09-07 performance audit's REAL
2413
+ * J1 — the CSV commit awaited createInstrumentRegistration per
2414
+ * imported row: ~10⁴ rows = minutes of serial D1 writes at demo
2415
+ * latency, two round trips each): every row lands exactly as the
2416
+ * single-row verb would land it, but the rows ride ONE db.batch per
2417
+ * INSTRUMENT_REGISTRATIONS_CHUNK rows, the INSERT OR IGNORE …
2418
+ * RETURNING * answering the stored row off the write itself (the
2419
+ * appendEvent halving — never the INSERT + SELECT-by-id pair).
2420
+ *
2421
+ * The per-row answer, aligned with the INPUT order: the stored row,
2422
+ * or NULL on the (certificate_id, serial_number) conflict the
2423
+ * single verb's honest null, so the route's per-row refusal leg (the
2424
+ * serial registered between the evaluation and the commit) rides
2425
+ * unchanged.
2426
+ *
2427
+ * The ordering contract: the statements ride each batch in input
2428
+ * order and the chunks issue SERIALLY, so the register's insertion
2429
+ * order IS the input's row order (the CSV row order; the commit's
2430
+ * chain events sequence after it, same order). A caller
2431
+ * parallelizing the call itself forfeits the contract.
2432
+ *
2433
+ * The failure contract mirrors putEntities: the chunk is the ATOMIC
2434
+ * unit (a D1 batch is all-or-nothing; the SQLite half wraps each
2435
+ * chunk in a transaction). A failed chunk lands NOTHING of its rows
2436
+ * and the call throws — earlier chunks' writes STAND, later chunks
2437
+ * never issue. An empty rows list resolves to [] without issuing a
2438
+ * statement. */
2439
+ createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]>
2353
2440
  /** The lifecycle act (registered ⇄ out_of_service → withdrawn; the
2354
2441
  * transition RULE is the route's — withdrawn is terminal): stamps
2355
2442
  * updated_at/by. NULL when the register does not carry the id. */
@@ -2420,13 +2507,21 @@ export interface ServerStore {
2420
2507
  * acting request's envelope). Answers the stored row (seq + at read
2421
2508
  * back) off the INSERT's own RETURNING — ONE round trip, never the
2422
2509
  * INSERT + SELECT-by-id pair. */
2423
- appendEvent(input: {
2424
- id: string
2425
- domain: string
2426
- entityId: string
2427
- action: string
2428
- payload: string
2429
- }): Promise<PlatformEvent>
2510
+ appendEvent(input: EventWriteInput): Promise<PlatformEvent>
2511
+ /** The BULK append (the 2026-09-07 performance audit's chain half —
2512
+ * the CSV registration commit's one chain event per imported serial,
2513
+ * a second serial loop after the register writes): every event lands
2514
+ * exactly as appendEvent would land it, the rows riding ONE
2515
+ * db.batch per APPEND_EVENTS_CHUNK rows. Answers the stored rows in
2516
+ * INPUT order — the seqs strictly increase in it (the statements
2517
+ * ride each batch in input order, the chunks SERIALLY; the same
2518
+ * semantic contract as putEntities').
2519
+ *
2520
+ * The failure contract mirrors putEntities: the chunk is the ATOMIC
2521
+ * unit; a failed chunk lands NOTHING of its events and the call
2522
+ * throws — earlier chunks stand, later chunks never issue. An empty
2523
+ * list resolves to [] without issuing a statement. */
2524
+ appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]>
2430
2525
  /** The feed's raw leg: events past the cursor, seq-ordered. The
2431
2526
  * visibility gate is the READER's layer (server/notify-feed.ts) —
2432
2527
  * never waived here, never duplicated into the SQL. */