@oimlsmart/platform-server 0.2.6 → 0.2.8

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,15 @@
1
+ -- The per-store journal high-water (the ServerStore seam's
2
+ -- latestChangeSeqFor(store), the 2026-09-07 performance audit's G1):
3
+ -- the seam's DOC-ONLY contract since 0.2.2 ("the (store, seq) walk —
4
+ -- the index rides the same commit") lands. The read answers MAX(seq)
5
+ -- over one store's slice of the ONE global journal — the seq stays
6
+ -- global and monotone, this is a projection, never a second sequence.
7
+ -- Without the index the projection scans the journal's rowid walk and
8
+ -- filters; with it the read is a single indexed probe, which is the
9
+ -- contract's whole point (per-store conditional revalidation / SSE
10
+ -- resume probes paying one read, never a payload fetch).
11
+ --
12
+ -- CREATE INDEX IF NOT EXISTS is the idempotent guard (the migration
13
+ -- contract's expand-only discipline; a consumer that already carries
14
+ -- the index converges). schema.sql's mirror lands in the same commit.
15
+ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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
@@ -32,6 +32,7 @@ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
32
32
  import {
33
33
  DEMO_PASSWORD,
34
34
  EVENTS_BULK_KEY_CHUNK,
35
+ PUT_ENTITIES_CHUNK,
35
36
  StoreUnavailable,
36
37
  type AccountEmail,
37
38
  type AddAccountEmailResult,
@@ -44,6 +45,7 @@ import {
44
45
  type EnrollmentToken,
45
46
  type EntityChange,
46
47
  type EntityRow,
48
+ type EntityWriteInput,
47
49
  type EventEntityKey,
48
50
  type EventKeyFilter,
49
51
  type FederationPeer,
@@ -413,6 +415,14 @@ function boundedD1Writes(binding: D1Database, budgetMs: number): D1Database {
413
415
  }
414
416
  }
415
417
 
418
+ /** The entity write's two statements, ONE textual source for putEntity
419
+ * and putEntities alike (the multi-row write must land each row
420
+ * byte-identically to the single-row verb — the upsert, then its
421
+ * journal entry). */
422
+ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
423
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
424
+ const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
425
+
416
426
  export class D1ServerStore implements ServerStore {
417
427
  /** The RAW binding — the ensure memos (and d1StoreFor's map) key on
418
428
  * it: the facade below is per-instance and would never hit. */
@@ -3450,15 +3460,34 @@ export class D1ServerStore implements ServerStore {
3450
3460
  // all-or-nothing, the same atomicity the SQLite path gets from its
3451
3461
  // transaction.
3452
3462
  await this.db.batch([
3453
- this.stmt(
3454
- `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
3455
- ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
3456
- store, id, orgId, data,
3457
- ),
3458
- this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'persist', id),
3463
+ this.stmt(ENTITY_UPSERT_SQL, store, id, orgId, data),
3464
+ this.stmt(ENTITY_CHANGE_SQL, store, 'persist', id),
3459
3465
  ])
3460
3466
  }
3461
3467
 
3468
+ async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
3469
+ // The seam's contract, mechanically: each row lands exactly as
3470
+ // putEntity would land it (the upsert, then its journal entry), the
3471
+ // statements ride each batch in INPUT order, and the chunks issue
3472
+ // SERIALLY — so the journal's seq order IS the input's row order
3473
+ // (the audit-chain property the CSV commit relies on). The chunk is
3474
+ // the atomic unit: a D1 batch lands all-or-nothing; a failed chunk
3475
+ // throws with its rows unlanded, earlier chunks standing, later
3476
+ // chunks never issued.
3477
+ if (rows.length === 0) return
3478
+ for (let i = 0; i < rows.length; i += PUT_ENTITIES_CHUNK) {
3479
+ const chunk = rows.slice(i, i + PUT_ENTITIES_CHUNK)
3480
+ const statements: D1PreparedStatement[] = []
3481
+ for (const row of chunk) {
3482
+ statements.push(
3483
+ this.stmt(ENTITY_UPSERT_SQL, store, row.id, row.orgId, row.data),
3484
+ this.stmt(ENTITY_CHANGE_SQL, store, 'persist', row.id),
3485
+ )
3486
+ }
3487
+ await this.db.batch(statements)
3488
+ }
3489
+ }
3490
+
3462
3491
  async deleteEntity(store: string, id: string): Promise<boolean> {
3463
3492
  const res = await this.stmt('DELETE FROM entities WHERE store = ? AND id = ?', store, id).run()
3464
3493
  const gone = (res.meta.changes ?? 0) > 0
@@ -3480,6 +3509,16 @@ export class D1ServerStore implements ServerStore {
3480
3509
  return row?.seq ?? 0
3481
3510
  }
3482
3511
 
3512
+ async latestChangeSeqFor(store: string): Promise<number> {
3513
+ // One indexed probe over idx_entity_changes_store_seq (migration
3514
+ // 0026) — the per-store projection of the one journal; 0 when the
3515
+ // store carries no rows (MAX answers NULL on the empty set).
3516
+ const row = await this.stmt(
3517
+ 'SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?', store,
3518
+ ).first<{ seq: number | null }>()
3519
+ return row?.seq ?? 0
3520
+ }
3521
+
3483
3522
  // ── the platform event store (TODO.notify/01) ─────────────────────
3484
3523
  // The event rows port directly (D1 is SQLite) — the same statements
3485
3524
  // as sqlite/events.ts's sync half.
@@ -13,12 +13,20 @@
13
13
  // ═══════════════════════════════════════════════════════════════════
14
14
 
15
15
  import { getDb } from './store'
16
- import { orgIdOf } from '../../store'
16
+ import { PUT_ENTITIES_CHUNK, orgIdOf } from '../../store'
17
17
 
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 } from '../../store'
21
+ import type { EntityRow, EntityChange, EntityWriteInput } from '../../store'
22
+
23
+ /** The entity write's two statements, ONE textual source for putEntity
24
+ * and putEntities alike (the multi-row write must land each row
25
+ * byte-identically to the single-row verb — the upsert, then its
26
+ * journal entry). */
27
+ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
28
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
29
+ const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
22
30
 
23
31
  export function listEntities(store: string): EntityRow[] {
24
32
  // The ORDER BY is the seam's contract (the 0.2.3 pin, the D1 half's
@@ -40,15 +48,35 @@ export function getEntity(store: string, id: string): EntityRow | undefined {
40
48
  export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
41
49
  const db = getDb()
42
50
  const write = db.transaction(() => {
43
- db.prepare(
44
- `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
45
- ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
46
- ).run(store, id, orgId, data)
47
- db.prepare('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)').run(store, 'persist', id)
51
+ db.prepare(ENTITY_UPSERT_SQL).run(store, id, orgId, data)
52
+ db.prepare(ENTITY_CHANGE_SQL).run(store, 'persist', id)
48
53
  })
49
54
  write()
50
55
  }
51
56
 
57
+ /** The multi-row write (the seam's putEntities, the 2026-09-07 audit's
58
+ * J1): each row lands exactly as putEntity would land it, in INPUT
59
+ * order, one transaction per PUT_ENTITIES_CHUNK rows — the chunk is
60
+ * the atomic unit, matching the D1 batch's all-or-nothing; a failed
61
+ * chunk throws with its rows unlanded, earlier chunks standing, later
62
+ * chunks never issued. The chunks run serially, so the journal's seq
63
+ * order IS the input's row order. */
64
+ export function putEntities(store: string, rows: readonly EntityWriteInput[]): void {
65
+ if (rows.length === 0) return
66
+ const db = getDb()
67
+ const upsert = db.prepare(ENTITY_UPSERT_SQL)
68
+ const journal = db.prepare(ENTITY_CHANGE_SQL)
69
+ for (let i = 0; i < rows.length; i += PUT_ENTITIES_CHUNK) {
70
+ const chunk = rows.slice(i, i + PUT_ENTITIES_CHUNK)
71
+ db.transaction(() => {
72
+ for (const row of chunk) {
73
+ upsert.run(store, row.id, row.orgId, row.data)
74
+ journal.run(store, 'persist', row.id)
75
+ }
76
+ })()
77
+ }
78
+ }
79
+
52
80
  export function deleteEntity(store: string, id: string): boolean {
53
81
  const db = getDb()
54
82
  let gone = false
@@ -74,3 +102,14 @@ export function latestChangeSeq(): number {
74
102
  const row = getDb().prepare('SELECT MAX(seq) AS seq FROM entity_changes').get() as { seq: number | null }
75
103
  return row.seq ?? 0
76
104
  }
105
+
106
+ /** The per-store projection of the one journal (the seam's contract):
107
+ * MAX(seq) over the store's own slice, 0 on a store with no writes.
108
+ * Walks idx_entity_changes_store_seq — one indexed probe, never a
109
+ * journal scan. */
110
+ export function latestChangeSeqFor(store: string): number {
111
+ const row = getDb()
112
+ .prepare('SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?')
113
+ .get(store) as { seq: number | null }
114
+ return row.seq ?? 0
115
+ }
@@ -132,6 +132,12 @@ CREATE TABLE IF NOT EXISTS entity_changes (
132
132
  id TEXT NOT NULL,
133
133
  at TEXT NOT NULL DEFAULT (datetime('now'))
134
134
  );
135
+ -- The per-store high-water's walk (migration 0026, the seam's
136
+ -- latestChangeSeqFor(store)): MAX(seq) over one store's slice of the
137
+ -- one global journal costs a single indexed probe, never a journal
138
+ -- scan. The seq stays global and monotone — a projection, never a
139
+ -- second sequence.
140
+ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
135
141
 
136
142
  -- TODO.notify/01 — the platform event store (the notification system's
137
143
  -- source of truth): one row per DECLARED notifiable act (the catalog,
@@ -84,7 +84,9 @@ import {
84
84
  deleteEntity,
85
85
  getEntity,
86
86
  latestChangeSeq,
87
+ latestChangeSeqFor,
87
88
  listEntities,
89
+ putEntities,
88
90
  putEntity,
89
91
  } from './sqlite/entities'
90
92
  import {
@@ -185,7 +187,7 @@ import {
185
187
  updateOpAccount,
186
188
  updateUserName,
187
189
  } from './sqlite/op-accounts-store'
188
- 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 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'
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'
189
191
  import {
190
192
  advanceWebauthnCounter,
191
193
  consumeMfaPending,
@@ -1013,6 +1015,9 @@ export function createSqliteServerStore(): ServerStore {
1013
1015
  async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
1014
1016
  putEntity(store, id, orgId, data)
1015
1017
  },
1018
+ async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
1019
+ putEntities(store, rows)
1020
+ },
1016
1021
  async deleteEntity(store: string, id: string): Promise<boolean> {
1017
1022
  return deleteEntity(store, id)
1018
1023
  },
@@ -1022,6 +1027,9 @@ export function createSqliteServerStore(): ServerStore {
1022
1027
  async latestChangeSeq(): Promise<number> {
1023
1028
  return latestChangeSeq()
1024
1029
  },
1030
+ async latestChangeSeqFor(store: string): Promise<number> {
1031
+ return latestChangeSeqFor(store)
1032
+ },
1025
1033
 
1026
1034
  // ── the platform event store (TODO.notify/01) ──
1027
1035
  async appendEvent(input: {
package/src/store.ts CHANGED
@@ -175,6 +175,26 @@ export interface EntityChange {
175
175
  at: string
176
176
  }
177
177
 
178
+ /** One row of the multi-row write (putEntities): putEntity's (id,
179
+ * orgId, data) legs — the store is the call's, once for the whole
180
+ * batch. */
181
+ export interface EntityWriteInput {
182
+ id: string
183
+ orgId: string | null
184
+ data: string
185
+ }
186
+
187
+ /** The multi-row write's batch chunk, in ROWS (putEntities): each row
188
+ * contributes two statements (the upsert + its journal entry), so a
189
+ * chunk is one db.batch of 2 × PUT_ENTITIES_CHUNK statements — the
190
+ * same conservative order as EVENTS_BULK_KEY_CHUNK. The chunk bounds
191
+ * the ATOMIC unit (a D1 batch is all-or-nothing; the SQLite half's
192
+ * per-chunk transaction matches it) and keeps one batch's latency
193
+ * well inside the write budget at demo-hub latency. Chunks issue
194
+ * SERIALLY, in input order — the journal's seq order IS the input
195
+ * order, and parallel chunks would forfeit it. */
196
+ export const PUT_ENTITIES_CHUNK = 50
197
+
178
198
  // ── the platform event store (TODO.notify/01) ────────────────────────
179
199
 
180
200
  /** A notifiable platform event (TODO.notify/00's event model): one row
@@ -2349,24 +2369,51 @@ export interface ServerStore {
2349
2369
  listEntities(store: string): Promise<EntityRow[]>
2350
2370
  getEntity(store: string, id: string): Promise<EntityRow | undefined>
2351
2371
  putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
2372
+ /** The MULTI-ROW write (the 2026-09-07 performance audit's J1 — the
2373
+ * CSV registration commit awaited putEntity per imported row, ~10⁴
2374
+ * rows = minutes of serial D1 writes at demo latency): every row
2375
+ * lands exactly as putEntity would land it (the upsert + its
2376
+ * journal 'persist' entry, one journal row per input row, a
2377
+ * duplicate id re-journals and the last write wins), but the rows
2378
+ * ride ONE db.batch per PUT_ENTITIES_CHUNK rows instead of one
2379
+ * round trip per row.
2380
+ *
2381
+ * The ordering contract — a semantic property of the audit chain,
2382
+ * never an implementation detail: the journal's seq order IS the
2383
+ * input's row order. The statements ride each batch in input order
2384
+ * and the chunks issue SERIALLY (parallel chunks would interleave
2385
+ * the seq assignment); a caller parallelizing the call itself
2386
+ * forfeits the contract.
2387
+ *
2388
+ * The failure contract: the chunk is the ATOMIC unit (a D1 batch is
2389
+ * all-or-nothing; the SQLite half wraps each chunk in a
2390
+ * transaction). A failed chunk lands NOTHING of its rows and the
2391
+ * call throws — but earlier chunks' writes STAND, and later chunks
2392
+ * never issue. A caller needing the whole import all-or-nothing
2393
+ * keeps it under one chunk (or compensates above the seam; the
2394
+ * retry re-journals, the same caveat as any putEntity retry — the
2395
+ * bounded-write facade's StoreUnavailable "may have landed"
2396
+ * posture). An empty rows list resolves without issuing a
2397
+ * statement. */
2398
+ putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void>
2352
2399
  deleteEntity(store: string, id: string): Promise<boolean>
2353
2400
  changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
2354
2401
  /** The GLOBAL journal high-water: the bootstrap snapshot's ETag leg
2355
2402
  * (any write anywhere bumps the one seq — conservative, never
2356
- * stale).
2357
- *
2358
- * NAMED FOLLOW-UP, DOC ONLY (the smart side references it from
2359
- * server/routes/bootstrap.ts: the per-store `seqs` snapshot carries
2360
- * the journal position a delta-loading client would resume from):
2361
- * the PER-STORE high-water — `latestChangeSeqFor(store)` — answering
2362
- * MAX(seq) over one store's slice of the SAME journal. The contract
2363
- * when it lands: the seq stays global and monotone (the per-store
2364
- * read is a PROJECTION of the one journal, never a second
2365
- * sequence); it exists so a per-store conditional revalidation /
2366
- * SSE resume probe costs one indexed read instead of a payload
2367
- * fetch; and its query wants the (store, seq) walk — the index
2368
- * rides the same commit, expand-only per the migration contract. */
2403
+ * stale). */
2369
2404
  latestChangeSeq(): Promise<number>
2405
+ /** The PER-STORE high-water: MAX(seq) over one store's slice of the
2406
+ * SAME journal. The seq stays global and monotone — the per-store
2407
+ * read is a PROJECTION of the one journal, never a second sequence.
2408
+ * A store with no writes answers 0 (the empty journal's floor, the
2409
+ * same as the global form's). The read is one indexed probe over
2410
+ * idx_entity_changes_store_seq (migration 0026 — the (store, seq)
2411
+ * walk rode that commit, expand-only per the migration contract),
2412
+ * so a per-store conditional revalidation / SSE resume probe never
2413
+ * pays a journal scan. Landed for the 2026-09-07 performance
2414
+ * audit's G1: the smart side's bootstrap composite ETag switches
2415
+ * from the global seq to the per-set composite of these. */
2416
+ latestChangeSeqFor(store: string): Promise<number>
2370
2417
 
2371
2418
  // ── the platform event store (TODO.notify/01) ──
2372
2419
  /** Append one declared event (the emitter's write; one row inside the