@oimlsmart/platform-server 0.2.12 → 0.2.14
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/migrations/0028_entities_certificate_number_index.sql +22 -0
- package/package.json +1 -1
- package/src/store/d1.ts +92 -1
- package/src/store/sqlite/entities.ts +65 -2
- package/src/store/sqlite/events.ts +23 -1
- package/src/store/sqlite/schema.sql +9 -0
- package/src/store/sqlite.ts +15 -3
- package/src/store.ts +88 -2
|
@@ -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.
|
|
3
|
+
"version": "0.2.14",
|
|
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,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
type EmailChangeToken,
|
|
47
48
|
type EnrollmentToken,
|
|
48
49
|
type EntityChange,
|
|
50
|
+
type EntityListOptions,
|
|
49
51
|
type EntityRow,
|
|
50
52
|
type EntityWriteInput,
|
|
51
53
|
type EventEntityKey,
|
|
@@ -88,6 +90,7 @@ import {
|
|
|
88
90
|
type InstrumentRegistrationLifecycle,
|
|
89
91
|
type InstrumentRegistrationScopeStatus,
|
|
90
92
|
type InstrumentRegistrationWriteInput,
|
|
93
|
+
type JournalAppend,
|
|
91
94
|
type PersonalAccessToken,
|
|
92
95
|
type PlatformEvent,
|
|
93
96
|
resolveOrgContext,
|
|
@@ -427,6 +430,43 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
|
|
|
427
430
|
ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
|
|
428
431
|
const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
|
|
429
432
|
|
|
433
|
+
/** The journal fan-out's ISOLATE-scope registry (the seam's
|
|
434
|
+
* onJournalAppend): one module-scope set — a registration through ANY
|
|
435
|
+
* D1ServerStore instance hears every journal append landing in this
|
|
436
|
+
* isolate, whichever instance wrote it. Cross-isolate writes never
|
|
437
|
+
* fire it (the database is the source of truth; the consumer's poll
|
|
438
|
+
* stays the fallback). */
|
|
439
|
+
const journalListeners = new Set<(appends: readonly JournalAppend[]) => void>()
|
|
440
|
+
|
|
441
|
+
/** Fires the registry with one write's appended triples, AFTER the
|
|
442
|
+
* write stands (the batch resolved / the row gone). Synchronous, in
|
|
443
|
+
* the write's continuation; a listener's throw is swallowed per
|
|
444
|
+
* listener — the write path never breaks for a listener. The snapshot
|
|
445
|
+
* keeps an unregistering listener's removal honest mid-emit. */
|
|
446
|
+
function emitJournalAppends(appends: readonly JournalAppend[]): void {
|
|
447
|
+
if (appends.length === 0 || journalListeners.size === 0) return
|
|
448
|
+
for (const listener of [...journalListeners]) {
|
|
449
|
+
try {
|
|
450
|
+
listener(appends)
|
|
451
|
+
} catch { /* a listener never breaks the write path */ }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The register's number lookup (findCertificatesByNumber): the keyed
|
|
456
|
+
* read against idx_entities_store_certificate_number (migration 0028)
|
|
457
|
+
* — the json_valid-guarded extract's exact spelling, COLLATE NOCASE
|
|
458
|
+
* for the register's case-insensitive number match. INDEXED BY pins
|
|
459
|
+
* the walk: without it the planner prefers idx_entities_store_org for
|
|
460
|
+
* the ORDER BY (org_id, rowid) — the seam's list order, kept so the
|
|
461
|
+
* first match IS the retiring listEntities scan's first match — and
|
|
462
|
+
* the "index" would walk the whole store; with it a database behind
|
|
463
|
+
* migration 0028 errors honestly, never scans silently. */
|
|
464
|
+
const CERTIFICATE_NUMBER_SQL = `SELECT store, id, org_id, data, updated_at
|
|
465
|
+
FROM entities INDEXED BY idx_entities_store_certificate_number
|
|
466
|
+
WHERE store = 'certificates'
|
|
467
|
+
AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE = ?
|
|
468
|
+
ORDER BY org_id, rowid`
|
|
469
|
+
|
|
430
470
|
/** The register write's INSERT — ONE textual source for
|
|
431
471
|
* createInstrumentRegistration and createInstrumentRegistrations alike
|
|
432
472
|
* (the batch verb appends RETURNING * so the stored row answers off
|
|
@@ -3503,7 +3543,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3503
3543
|
|
|
3504
3544
|
// ── the workflow entity store + change journal ───────────────────
|
|
3505
3545
|
|
|
3506
|
-
async listEntities(store: string): Promise<EntityRow[]> {
|
|
3546
|
+
async listEntities(store: string, options?: EntityListOptions): Promise<EntityRow[]> {
|
|
3507
3547
|
// The ORDER BY is the seam's contract (the 0.2.3 pin): the answer
|
|
3508
3548
|
// arrives in (org_id, rowid) order — the read's observable order
|
|
3509
3549
|
// since migration 0001, when the planner walked
|
|
@@ -3512,6 +3552,19 @@ export class D1ServerStore implements ServerStore {
|
|
|
3512
3552
|
// insertion (the smart app's render-baseline red, oimlsmart/smart
|
|
3513
3553
|
// PR #264) — a list read's order is a consumer-visible contract,
|
|
3514
3554
|
// never the planner's pick.
|
|
3555
|
+
// options.orgId narrows the candidate set (the seam's EntityListOptions
|
|
3556
|
+
// — the portal-load audit's R3-fix3): the kept groups (NULL stamps,
|
|
3557
|
+
// then the named org) are the two lowest org_id buckets, so the
|
|
3558
|
+
// filtered ORDER BY is the unfiltered order's restriction to the
|
|
3559
|
+
// candidates — a gate-driven projection of either answer is
|
|
3560
|
+
// byte-identical.
|
|
3561
|
+
if (options?.orgId) {
|
|
3562
|
+
const res = await this.stmt(
|
|
3563
|
+
'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? AND (org_id = ? OR org_id IS NULL) ORDER BY org_id, rowid',
|
|
3564
|
+
store, options.orgId,
|
|
3565
|
+
).all<EntityRow>()
|
|
3566
|
+
return res.results
|
|
3567
|
+
}
|
|
3515
3568
|
const res = await this.stmt(
|
|
3516
3569
|
'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? ORDER BY org_id, rowid', store,
|
|
3517
3570
|
).all<EntityRow>()
|
|
@@ -3525,6 +3578,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
3525
3578
|
return row ?? undefined
|
|
3526
3579
|
}
|
|
3527
3580
|
|
|
3581
|
+
async findCertificatesByNumber(number: string): Promise<EntityRow[]> {
|
|
3582
|
+
const res = await this.stmt(CERTIFICATE_NUMBER_SQL, number).all<EntityRow>()
|
|
3583
|
+
return res.results
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3528
3586
|
async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
|
|
3529
3587
|
// The upsert + its journal entry ride ONE batch — D1 batches are
|
|
3530
3588
|
// all-or-nothing, the same atomicity the SQLite path gets from its
|
|
@@ -3533,6 +3591,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3533
3591
|
this.stmt(ENTITY_UPSERT_SQL, store, id, orgId, data),
|
|
3534
3592
|
this.stmt(ENTITY_CHANGE_SQL, store, 'persist', id),
|
|
3535
3593
|
])
|
|
3594
|
+
emitJournalAppends([{ store, type: 'persist', id }])
|
|
3536
3595
|
}
|
|
3537
3596
|
|
|
3538
3597
|
async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
|
|
@@ -3555,6 +3614,10 @@ export class D1ServerStore implements ServerStore {
|
|
|
3555
3614
|
)
|
|
3556
3615
|
}
|
|
3557
3616
|
await this.db.batch(statements)
|
|
3617
|
+
// The fan-out fires per LANDED chunk (the triples in input order)
|
|
3618
|
+
// — a failed chunk throws before its emit, so a listener never
|
|
3619
|
+
// hears of rows that did not stand.
|
|
3620
|
+
emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
|
|
3558
3621
|
}
|
|
3559
3622
|
}
|
|
3560
3623
|
|
|
@@ -3563,6 +3626,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3563
3626
|
const gone = (res.meta.changes ?? 0) > 0
|
|
3564
3627
|
if (gone) {
|
|
3565
3628
|
await this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'remove', id).run()
|
|
3629
|
+
emitJournalAppends([{ store, type: 'remove', id }])
|
|
3566
3630
|
}
|
|
3567
3631
|
return gone
|
|
3568
3632
|
}
|
|
@@ -3589,6 +3653,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
3589
3653
|
return row?.seq ?? 0
|
|
3590
3654
|
}
|
|
3591
3655
|
|
|
3656
|
+
onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void {
|
|
3657
|
+
journalListeners.add(listener)
|
|
3658
|
+
return () => { journalListeners.delete(listener) }
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3592
3661
|
// ── the platform event store (TODO.notify/01) ─────────────────────
|
|
3593
3662
|
// The event rows port directly (D1 is SQLite) — the same statements
|
|
3594
3663
|
// as sqlite/events.ts's sync half.
|
|
@@ -3659,6 +3728,28 @@ export class D1ServerStore implements ServerStore {
|
|
|
3659
3728
|
return row ? D1ServerStore.toPlatformEvent(row) : null
|
|
3660
3729
|
}
|
|
3661
3730
|
|
|
3731
|
+
/** The bulk by-id read (the seam's contract, mechanically): every id
|
|
3732
|
+
* resolves in ONE statement per EVENTS_ID_CHUNK ids — the IN walk
|
|
3733
|
+
* against the id UNIQUE index (never a compound SELECT: the D1
|
|
3734
|
+
* 5-term cap rule), the chunks serial — and the answer is
|
|
3735
|
+
* INPUT-ALIGNED: position i carries the row for ids[i], null where
|
|
3736
|
+
* no event carries the id (the per-id getEvent loop's exact
|
|
3737
|
+
* answers; a duplicate id answers its row at every position). An
|
|
3738
|
+
* empty list answers [] without issuing a statement. */
|
|
3739
|
+
async getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]> {
|
|
3740
|
+
if (ids.length === 0) return []
|
|
3741
|
+
const byId = new Map<string, PlatformEvent>()
|
|
3742
|
+
for (let i = 0; i < ids.length; i += EVENTS_ID_CHUNK) {
|
|
3743
|
+
const chunk = ids.slice(i, i + EVENTS_ID_CHUNK)
|
|
3744
|
+
const marks = chunk.map(() => '?').join(', ')
|
|
3745
|
+
const res = await this.stmt(
|
|
3746
|
+
`SELECT * FROM events WHERE id IN (${marks})`, ...chunk,
|
|
3747
|
+
).all<Record<string, unknown>>()
|
|
3748
|
+
for (const row of res.results) byId.set(row.id as string, D1ServerStore.toPlatformEvent(row))
|
|
3749
|
+
}
|
|
3750
|
+
return ids.map(id => byId.get(id) ?? null)
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3662
3753
|
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
3663
3754
|
* by equality; the free legs stay out of the WHERE. The BULK form
|
|
3664
3755
|
* (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, EntityListOptions, 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,12 +54,38 @@ 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
|
|
|
31
|
-
|
|
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
|
+
|
|
72
|
+
export function listEntities(store: string, options?: EntityListOptions): 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
|
|
34
75
|
// migration 0001's idx_entities_store_org walk, made planner-proof
|
|
35
76
|
// when migration 0023's expression index offered a second
|
|
36
77
|
// store-prefixed plan.
|
|
78
|
+
// options.orgId narrows the candidate set (the seam's EntityListOptions
|
|
79
|
+
// — the portal-load audit's R3-fix3): the kept groups (NULL stamps,
|
|
80
|
+
// then the named org) are the two lowest org_id buckets, so the
|
|
81
|
+
// filtered ORDER BY is the unfiltered order's restriction to the
|
|
82
|
+
// candidates — a gate-driven projection of either answer is
|
|
83
|
+
// byte-identical.
|
|
84
|
+
if (options?.orgId) {
|
|
85
|
+
return getDb()
|
|
86
|
+
.prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? AND (org_id = ? OR org_id IS NULL) ORDER BY org_id, rowid')
|
|
87
|
+
.all(store, options.orgId) as EntityRow[]
|
|
88
|
+
}
|
|
37
89
|
return getDb()
|
|
38
90
|
.prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? ORDER BY org_id, rowid')
|
|
39
91
|
.all(store) as EntityRow[]
|
|
@@ -45,6 +97,12 @@ export function getEntity(store: string, id: string): EntityRow | undefined {
|
|
|
45
97
|
.get(store, id) as EntityRow | undefined
|
|
46
98
|
}
|
|
47
99
|
|
|
100
|
+
/** The register's keyed number lookup — one index walk, never the
|
|
101
|
+
* listEntities scan + per-row JSON parse. */
|
|
102
|
+
export function findCertificatesByNumber(number: string): EntityRow[] {
|
|
103
|
+
return getDb().prepare(CERTIFICATE_NUMBER_SQL).all(number) as EntityRow[]
|
|
104
|
+
}
|
|
105
|
+
|
|
48
106
|
export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
|
|
49
107
|
const db = getDb()
|
|
50
108
|
const write = db.transaction(() => {
|
|
@@ -52,6 +110,7 @@ export function putEntity(store: string, id: string, orgId: string | null, data:
|
|
|
52
110
|
db.prepare(ENTITY_CHANGE_SQL).run(store, 'persist', id)
|
|
53
111
|
})
|
|
54
112
|
write()
|
|
113
|
+
emitJournalAppends([{ store, type: 'persist', id }])
|
|
55
114
|
}
|
|
56
115
|
|
|
57
116
|
/** The multi-row write (the seam's putEntities, the 2026-09-07 audit's
|
|
@@ -74,6 +133,9 @@ export function putEntities(store: string, rows: readonly EntityWriteInput[]): v
|
|
|
74
133
|
journal.run(store, 'persist', row.id)
|
|
75
134
|
}
|
|
76
135
|
})()
|
|
136
|
+
// The fan-out fires per LANDED chunk (the D1 half's posture) — a
|
|
137
|
+
// failed chunk throws before its emit.
|
|
138
|
+
emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
|
|
77
139
|
}
|
|
78
140
|
}
|
|
79
141
|
|
|
@@ -88,6 +150,7 @@ export function deleteEntity(store: string, id: string): boolean {
|
|
|
88
150
|
}
|
|
89
151
|
})
|
|
90
152
|
write()
|
|
153
|
+
if (gone) emitJournalAppends([{ store, type: 'remove', id }])
|
|
91
154
|
return gone
|
|
92
155
|
}
|
|
93
156
|
|
|
@@ -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).
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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 EntityListOptions, 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,
|
|
@@ -1012,12 +1015,15 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
1012
1015
|
},
|
|
1013
1016
|
|
|
1014
1017
|
// ── the workflow entity store + change journal ──
|
|
1015
|
-
async listEntities(store: string): Promise<EntityRow[]> {
|
|
1016
|
-
return listEntities(store)
|
|
1018
|
+
async listEntities(store: string, options?: EntityListOptions): Promise<EntityRow[]> {
|
|
1019
|
+
return listEntities(store, options)
|
|
1017
1020
|
},
|
|
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
|
@@ -138,6 +138,29 @@ export interface EntityRow {
|
|
|
138
138
|
updated_at: string
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** listEntities' candidate narrowing (the 2026-09-01 portal-load audit,
|
|
142
|
+
* R3-fix3): the entities table carries an indexed org_id column
|
|
143
|
+
* (idx_entities_store_org, migration 0001) that reads never used —
|
|
144
|
+
* every list scanned the whole store and left the org cone to the
|
|
145
|
+
* consumer's per-row gate. */
|
|
146
|
+
export interface EntityListOptions {
|
|
147
|
+
/** Narrow the CANDIDATE set to the rows stamped under this org (org_id
|
|
148
|
+
* = the value — orgIdOf's first-org-field stamp, or the writer's own
|
|
149
|
+
* org on the stamping write paths) PLUS the unstamped rows (org_id IS
|
|
150
|
+
* NULL — a stamp-less row is never excluded: the stamp's absence says
|
|
151
|
+
* nothing about the row's cone). A candidate filter, NEVER a
|
|
152
|
+
* visibility decision: which rows of the narrowed set answer is the
|
|
153
|
+
* consumer's in-memory gate, authoritative as before. The consumer
|
|
154
|
+
* enables the filter only where its gate provably denies every row
|
|
155
|
+
* the filter drops (the per-store verification rides the consumer's
|
|
156
|
+
* change — org_id stamps the FIRST org field, so multi-org-field
|
|
157
|
+
* stores are never narrowed). The answer keeps the seam's declared
|
|
158
|
+
* order: (org_id, rowid) over the kept rows — the unfiltered order's
|
|
159
|
+
* restriction to the candidate set, so a gate-driven projection of
|
|
160
|
+
* either answer is byte-identical. */
|
|
161
|
+
orgId?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
141
164
|
// ── federation peers (TODO.federation/04) ────────────────────────────
|
|
142
165
|
|
|
143
166
|
/** A pinned federation peer: the counterparty instance's descriptor,
|
|
@@ -175,6 +198,16 @@ export interface EntityChange {
|
|
|
175
198
|
at: string
|
|
176
199
|
}
|
|
177
200
|
|
|
201
|
+
/** The journal fan-out's payload (onJournalAppend): the (store, type,
|
|
202
|
+
* id) triple one entity_changes row carries. seq/at are the
|
|
203
|
+
* database's (the write path never reads them back) — the consumer
|
|
204
|
+
* re-reads via changesAfter from its own cursor. */
|
|
205
|
+
export interface JournalAppend {
|
|
206
|
+
store: string
|
|
207
|
+
type: 'persist' | 'remove'
|
|
208
|
+
id: string
|
|
209
|
+
}
|
|
210
|
+
|
|
178
211
|
/** One row of the multi-row write (putEntities): putEntity's (id,
|
|
179
212
|
* orgId, data) legs — the store is the call's, once for the whole
|
|
180
213
|
* batch. */
|
|
@@ -265,6 +298,14 @@ export interface EventEntityKey {
|
|
|
265
298
|
* backends stay answer-identical. */
|
|
266
299
|
export const EVENTS_BULK_KEY_CHUNK = 49
|
|
267
300
|
|
|
301
|
+
/** The bulk by-id read's statement chunk (getEvents): D1 caps a
|
|
302
|
+
* statement at 100 bound parameters and the IN list binds ONE per id
|
|
303
|
+
* (no limit parameter — the answer size is the matched-id count), so
|
|
304
|
+
* a bulk call issues ceil(ids / 99) statements — ONE for any
|
|
305
|
+
* realistic digest window — never one per id. The SQLite half chunks
|
|
306
|
+
* identically so the two backends stay answer-identical. */
|
|
307
|
+
export const EVENTS_ID_CHUNK = 99
|
|
308
|
+
|
|
268
309
|
/** The bulk append's chunk, in ROWS (appendEvents): each event
|
|
269
310
|
* contributes ONE statement (the INSERT … RETURNING * answers the
|
|
270
311
|
* stored row off the write itself, the appendEvent halving), so a
|
|
@@ -2467,9 +2508,28 @@ export interface ServerStore {
|
|
|
2467
2508
|
* first, then by org id, then by insertion). Both backends spell the
|
|
2468
2509
|
* ORDER BY explicitly — a list read's order is a consumer-visible
|
|
2469
2510
|
* contract, never the planner's pick (the 0.2.3 pin, after
|
|
2470
|
-
* migration 0023's expression index flipped the unnamed walk).
|
|
2471
|
-
|
|
2511
|
+
* migration 0023's expression index flipped the unnamed walk).
|
|
2512
|
+
* `options.orgId` narrows the candidate set in SQL (EntityListOptions
|
|
2513
|
+
* — the portal-load audit's R3-fix3); the narrowed answer is the same
|
|
2514
|
+
* order's restriction to the candidates. */
|
|
2515
|
+
listEntities(store: string, options?: EntityListOptions): Promise<EntityRow[]>
|
|
2472
2516
|
getEntity(store: string, id: string): Promise<EntityRow | undefined>
|
|
2517
|
+
/** The public register's certificate-number lookup (the verify
|
|
2518
|
+
* surfaces' keyed read — the 2026-09-07 performance audit's
|
|
2519
|
+
* public-lookup seam): the register resolved a number by
|
|
2520
|
+
* full-scanning listEntities('certificates') and JSON-parsing every
|
|
2521
|
+
* row to case-fold certificate_number — O(store rows) per lookup.
|
|
2522
|
+
* The keyed read walks idx_entities_store_certificate_number
|
|
2523
|
+
* (migration 0028): the json_valid-guarded extract, COLLATE NOCASE
|
|
2524
|
+
* (the register's number match is case-insensitive; the fold is
|
|
2525
|
+
* ASCII, the number grammar's alphabet). Answers every match in the
|
|
2526
|
+
* seam's list order (org_id, rowid), so the first match IS the
|
|
2527
|
+
* retiring scan's first match. The statement INDEXED BY-pins the
|
|
2528
|
+
* walk: a database behind migration 0028 errors honestly, never
|
|
2529
|
+
* scans silently. The hardcoded store name follows the
|
|
2530
|
+
* lastAccountSignIns 'auditEvents' precedent — the read's semantics
|
|
2531
|
+
* ARE the consumer's data convention. */
|
|
2532
|
+
findCertificatesByNumber(number: string): Promise<EntityRow[]>
|
|
2473
2533
|
putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
|
|
2474
2534
|
/** The MULTI-ROW write (the 2026-09-07 performance audit's J1 — the
|
|
2475
2535
|
* CSV registration commit awaited putEntity per imported row, ~10⁴
|
|
@@ -2516,6 +2576,21 @@ export interface ServerStore {
|
|
|
2516
2576
|
* audit's G1: the smart side's bootstrap composite ETag switches
|
|
2517
2577
|
* from the global seq to the per-set composite of these. */
|
|
2518
2578
|
latestChangeSeqFor(store: string): Promise<number>
|
|
2579
|
+
/** The journal fan-out (the SSE stream's true-push wake): registers a
|
|
2580
|
+
* listener on the ISOLATE-scope registry — every entity_changes
|
|
2581
|
+
* append landing in THIS isolate (putEntity's entry, each putEntities
|
|
2582
|
+
* chunk's entries as the chunk lands, deleteEntity's remove) fires it
|
|
2583
|
+
* synchronously AFTER the write stands, with the appended (store,
|
|
2584
|
+
* type, id) triples in the write's input order. The answer is the
|
|
2585
|
+
* unregister (idempotent).
|
|
2586
|
+
*
|
|
2587
|
+
* The honesty boundary: the journal's source of truth is the
|
|
2588
|
+
* DATABASE — a write landing in ANOTHER isolate (a sibling Worker)
|
|
2589
|
+
* never fires this isolate's listeners, so a consumer keeps its poll
|
|
2590
|
+
* as the fallback and treats the fan-out as a wake-up hint, never a
|
|
2591
|
+
* correctness channel. A listener's throw is swallowed per listener:
|
|
2592
|
+
* the write path never breaks for a listener. */
|
|
2593
|
+
onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void
|
|
2519
2594
|
|
|
2520
2595
|
// ── the platform event store (TODO.notify/01) ──
|
|
2521
2596
|
/** Append one declared event (the emitter's write; one row inside the
|
|
@@ -2545,6 +2620,17 @@ export interface ServerStore {
|
|
|
2545
2620
|
/** The by-id read (the inbox state write's guard — TODO.notify/03: a
|
|
2546
2621
|
* marker lands only on an event that exists and is the caller's). */
|
|
2547
2622
|
getEvent(id: string): Promise<PlatformEvent | null>
|
|
2623
|
+
/** The BULK by-id read (the notify digest's event join): the digest
|
|
2624
|
+
* run awaited getEvent once per queued delivery row — N serial round
|
|
2625
|
+
* trips for N rows. The bulk form resolves every id in ONE statement
|
|
2626
|
+
* per chunk of EVENTS_ID_CHUNK ids (the IN walk against the id UNIQUE
|
|
2627
|
+
* index; never a compound SELECT — the D1 5-term cap rule).
|
|
2628
|
+
*
|
|
2629
|
+
* The answer contract: INPUT-ALIGNED — position i answers the row for
|
|
2630
|
+
* ids[i], null where no event carries the id (exactly the per-id
|
|
2631
|
+
* getEvent loop's answers; a duplicate id answers its row at every
|
|
2632
|
+
* position). An empty list answers [] without issuing a statement. */
|
|
2633
|
+
getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]>
|
|
2548
2634
|
/** The subscription grammar's SQL resolution: the columns the pattern
|
|
2549
2635
|
* pins, equality-matched (`WHERE domain = ? AND entity_id = ?` — the
|
|
2550
2636
|
* column split's whole point, never a string scan on a composed key). */
|