@oimlsmart/platform-server 0.2.10 → 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.10",
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. */
@@ -3423,23 +3441,10 @@ export class D1ServerStore implements ServerStore {
3423
3441
 
3424
3442
  /** Register the instrument; NULL on the (certificate, serial)
3425
3443
  * conflict (the route's honest 409). */
3426
- async createInstrumentRegistration(input: {
3427
- id: string
3428
- certificateId: string
3429
- holderOrgId: string
3430
- standardId: string
3431
- serialNumber: string
3432
- manufactureDate?: string | null
3433
- designations?: Record<string, unknown>
3434
- scopeStatus: InstrumentRegistrationScopeStatus
3435
- scopeDetail?: string | null
3436
- registeredBy?: string | null
3437
- }): Promise<InstrumentRegistration | null> {
3444
+ async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
3438
3445
  await this.ensureInstrumentRegistrationSupport()
3439
3446
  const res = await this.stmt(
3440
- `INSERT OR IGNORE INTO instrument_registrations
3441
- (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
3442
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3447
+ INSTRUMENT_REGISTRATION_INSERT_SQL,
3443
3448
  input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
3444
3449
  input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
3445
3450
  input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
@@ -3448,6 +3453,37 @@ export class D1ServerStore implements ServerStore {
3448
3453
  return this.getInstrumentRegistration(input.id)
3449
3454
  }
3450
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
+
3451
3487
  /** The lifecycle act (the transition rule is the route's); stamps
3452
3488
  * updated_at/by. NULL when the register does not carry the id. */
3453
3489
  async setInstrumentRegistrationLifecycle(
@@ -3568,24 +3604,42 @@ export class D1ServerStore implements ServerStore {
3568
3604
  }
3569
3605
  }
3570
3606
 
3571
- async appendEvent(input: {
3572
- id: string
3573
- domain: string
3574
- entityId: string
3575
- action: string
3576
- payload: string
3577
- }): Promise<PlatformEvent> {
3607
+ async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
3578
3608
  // ONE round trip: the RETURNING clause (SQLite ≥ 3.35, D1 included)
3579
3609
  // answers the stored row — seq + the default at — off the INSERT
3580
3610
  // itself; the SELECT-by-id read-back retired (the 2026-09-06 audit:
3581
3611
  // two round trips per event, halved).
3582
3612
  const row = await this.stmt(
3583
- 'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *',
3613
+ EVENT_INSERT_SQL,
3584
3614
  input.id, input.domain, input.entityId, input.action, input.payload,
3585
3615
  ).first<Record<string, unknown>>()
3586
3616
  return D1ServerStore.toPlatformEvent(row!)
3587
3617
  }
3588
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
+
3589
3643
  async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
3590
3644
  const res = await this.stmt(
3591
3645
  'SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
@@ -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()
@@ -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,
@@ -189,7 +191,7 @@ import {
189
191
  updateOpAccount,
190
192
  updateUserName,
191
193
  } from './sqlite/op-accounts-store'
192
- 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'
193
195
  import {
194
196
  advanceWebauthnCounter,
195
197
  consumeMfaPending,
@@ -571,20 +573,12 @@ export function createSqliteServerStore(): ServerStore {
571
573
  async getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null> {
572
574
  return getInstrumentRegistration(id)
573
575
  },
574
- async createInstrumentRegistration(input: {
575
- id: string
576
- certificateId: string
577
- holderOrgId: string
578
- standardId: string
579
- serialNumber: string
580
- manufactureDate?: string | null
581
- designations?: Record<string, unknown>
582
- scopeStatus: InstrumentRegistrationScopeStatus
583
- scopeDetail?: string | null
584
- registeredBy?: string | null
585
- }): Promise<InstrumentRegistration | null> {
576
+ async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
586
577
  return createInstrumentRegistration(input)
587
578
  },
579
+ async createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]> {
580
+ return createInstrumentRegistrations(rows)
581
+ },
588
582
  async setInstrumentRegistrationLifecycle(
589
583
  id: string,
590
584
  lifecycle: InstrumentRegistrationLifecycle,
@@ -1044,15 +1038,12 @@ export function createSqliteServerStore(): ServerStore {
1044
1038
  },
1045
1039
 
1046
1040
  // ── the platform event store (TODO.notify/01) ──
1047
- async appendEvent(input: {
1048
- id: string
1049
- domain: string
1050
- entityId: string
1051
- action: string
1052
- payload: string
1053
- }): Promise<PlatformEvent> {
1041
+ async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
1054
1042
  return appendEvent(input)
1055
1043
  },
1044
+ async appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]> {
1045
+ return appendEvents(events)
1046
+ },
1056
1047
  async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
1057
1048
  return eventsAfter(seq, limit)
1058
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
@@ -2357,18 +2408,35 @@ export interface ServerStore {
2357
2408
  /** Register the instrument. NULL on the (certificate_id,
2358
2409
  * serial_number) conflict — the same physical unit never registers
2359
2410
  * twice under one certificate (the route's honest 409). */
2360
- createInstrumentRegistration(input: {
2361
- id: string
2362
- certificateId: string
2363
- holderOrgId: string
2364
- standardId: string
2365
- serialNumber: string
2366
- manufactureDate?: string | null
2367
- designations?: Record<string, unknown>
2368
- scopeStatus: InstrumentRegistrationScopeStatus
2369
- scopeDetail?: string | null
2370
- registeredBy?: string | null
2371
- }): 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)[]>
2372
2440
  /** The lifecycle act (registered ⇄ out_of_service → withdrawn; the
2373
2441
  * transition RULE is the route's — withdrawn is terminal): stamps
2374
2442
  * updated_at/by. NULL when the register does not carry the id. */
@@ -2439,13 +2507,21 @@ export interface ServerStore {
2439
2507
  * acting request's envelope). Answers the stored row (seq + at read
2440
2508
  * back) off the INSERT's own RETURNING — ONE round trip, never the
2441
2509
  * INSERT + SELECT-by-id pair. */
2442
- appendEvent(input: {
2443
- id: string
2444
- domain: string
2445
- entityId: string
2446
- action: string
2447
- payload: string
2448
- }): 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[]>
2449
2525
  /** The feed's raw leg: events past the cursor, seq-ordered. The
2450
2526
  * visibility gate is the READER's layer (server/notify-feed.ts) —
2451
2527
  * never waived here, never duplicated into the SQL. */