@oimlsmart/platform-server 0.2.0 → 0.2.2
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/0023_entities_action_index.sql +24 -0
- package/package.json +1 -1
- package/src/store/d1.ts +64 -25
- package/src/store/sqlite/events.ts +28 -8
- package/src/store/sqlite/op-accounts-store.ts +17 -12
- package/src/store/sqlite/schema.sql +12 -0
- package/src/store/sqlite.ts +8 -9
- package/src/store.ts +58 -3
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- The 2026-09-06 performance audit's audit-chain read: the OP console's
|
|
2
|
+
-- last-sign-in-per-account read (lastAccountSignIns) string-matched
|
|
3
|
+
-- EVERY auditEvents row's data blob (two data LIKE '%…%' clauses —
|
|
4
|
+
-- O(journal) per call, degrading as the journal grows). The auditEvents
|
|
5
|
+
-- rows are entities rows whose data JSON carries the typed legs
|
|
6
|
+
-- (action, entity_id, timestamp), so the read compiles to json_extract
|
|
7
|
+
-- equality — and this expression index makes it a walk over the
|
|
8
|
+
-- (store, action) slice instead of the journal: only the sign-in rows
|
|
9
|
+
-- are ever visited.
|
|
10
|
+
--
|
|
11
|
+
-- The json_valid GUARD is load-bearing: an unguarded json_extract index
|
|
12
|
+
-- expression RAISES 'malformed JSON' on any corrupt entities row —
|
|
13
|
+
-- the corrupt row would become unwritable, and the unguarded query
|
|
14
|
+
-- would throw where the retired LIKE fold skipped. The CASE guard
|
|
15
|
+
-- lands the corrupt row's legs at NULL (indexed, never matched); the
|
|
16
|
+
-- query spells the identical expression so the planner proves the
|
|
17
|
+
-- index applies. Expand-only per the migration contract; schema.sql's
|
|
18
|
+
-- mirror lands in the same commit.
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_entities_store_action ON entities (
|
|
20
|
+
store,
|
|
21
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.action'),
|
|
22
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id'),
|
|
23
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.timestamp')
|
|
24
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
|
|
32
32
|
import {
|
|
33
33
|
DEMO_PASSWORD,
|
|
34
|
+
EVENTS_BULK_KEY_CHUNK,
|
|
34
35
|
StoreUnavailable,
|
|
35
36
|
type AccountEmail,
|
|
36
37
|
type AddAccountEmailResult,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
type EnrollmentToken,
|
|
43
44
|
type EntityChange,
|
|
44
45
|
type EntityRow,
|
|
46
|
+
type EventEntityKey,
|
|
45
47
|
type EventKeyFilter,
|
|
46
48
|
type FederationPeer,
|
|
47
49
|
type IdentityApproval,
|
|
@@ -1931,22 +1933,30 @@ export class D1ServerStore implements ServerStore {
|
|
|
1931
1933
|
|
|
1932
1934
|
/** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
|
|
1933
1935
|
* newest auditEvents row whose action is a sign-in
|
|
1934
|
-
* ('account.sign_in' / 'upstream_sign_in') per entity_id.
|
|
1936
|
+
* ('account.sign_in' / 'upstream_sign_in') per entity_id. The typed
|
|
1937
|
+
* read (the 2026-09-06 audit): the legs come out of the data JSON by
|
|
1938
|
+
* json_extract against idx_entities_store_action (migration 0023) —
|
|
1939
|
+
* the sign-in slice is an index walk, never the O(journal) data-LIKE
|
|
1940
|
+
* scan. The json_valid guard spells the index's expression exactly
|
|
1941
|
+
* (a corrupt entities row answers NULL legs, never a raised
|
|
1942
|
+
* 'malformed JSON'). The action match stays exact (the retired
|
|
1943
|
+
* LIKE's closing quote made it so too) — and now spelling-proof:
|
|
1944
|
+
* the legs parse the JSON, so a serialized-with-spaces row answers
|
|
1945
|
+
* and an embedded lookalike substring never does. */
|
|
1935
1946
|
async lastAccountSignIns(): Promise<Record<string, string>> {
|
|
1936
1947
|
const res = await this.stmt(
|
|
1937
|
-
`SELECT data
|
|
1948
|
+
`SELECT json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id') AS entity_id,
|
|
1949
|
+
MAX(json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.timestamp')) AS ts
|
|
1950
|
+
FROM entities
|
|
1938
1951
|
WHERE store = 'auditEvents'
|
|
1939
|
-
AND (data
|
|
1940
|
-
|
|
1952
|
+
AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.action') IN ('account.sign_in', 'upstream_sign_in')
|
|
1953
|
+
GROUP BY json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id')`,
|
|
1954
|
+
).all<{ entity_id: string | null; ts: string | null }>()
|
|
1941
1955
|
const out: Record<string, string> = {}
|
|
1942
|
-
for (const {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
if (!out[event.entity_id] || event.timestamp > out[event.entity_id]!) {
|
|
1947
|
-
out[event.entity_id] = event.timestamp
|
|
1948
|
-
}
|
|
1949
|
-
} catch { /* a malformed audit row is skipped, never trusted */ }
|
|
1956
|
+
for (const { entity_id, ts } of res.results) {
|
|
1957
|
+
// A row missing either leg (the malformed audit row) is skipped,
|
|
1958
|
+
// never trusted — the same posture the JSON fold held.
|
|
1959
|
+
if (typeof entity_id === 'string' && typeof ts === 'string') out[entity_id] = ts
|
|
1950
1960
|
}
|
|
1951
1961
|
return out
|
|
1952
1962
|
}
|
|
@@ -3296,11 +3306,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
3296
3306
|
action: string
|
|
3297
3307
|
payload: string
|
|
3298
3308
|
}): Promise<PlatformEvent> {
|
|
3299
|
-
|
|
3300
|
-
|
|
3309
|
+
// ONE round trip: the RETURNING clause (SQLite ≥ 3.35, D1 included)
|
|
3310
|
+
// answers the stored row — seq + the default at — off the INSERT
|
|
3311
|
+
// itself; the SELECT-by-id read-back retired (the 2026-09-06 audit:
|
|
3312
|
+
// two round trips per event, halved).
|
|
3313
|
+
const row = await this.stmt(
|
|
3314
|
+
'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *',
|
|
3301
3315
|
input.id, input.domain, input.entityId, input.action, input.payload,
|
|
3302
|
-
).
|
|
3303
|
-
const row = await this.stmt('SELECT * FROM events WHERE id = ?', input.id).first<Record<string, unknown>>()
|
|
3316
|
+
).first<Record<string, unknown>>()
|
|
3304
3317
|
return D1ServerStore.toPlatformEvent(row!)
|
|
3305
3318
|
}
|
|
3306
3319
|
|
|
@@ -3322,8 +3335,28 @@ export class D1ServerStore implements ServerStore {
|
|
|
3322
3335
|
}
|
|
3323
3336
|
|
|
3324
3337
|
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
3325
|
-
* by equality; the free legs stay out of the WHERE.
|
|
3326
|
-
|
|
3338
|
+
* by equality; the free legs stay out of the WHERE. The BULK form
|
|
3339
|
+
* (the 2026-09-06 audit's notify-inbox seam) resolves every pinned
|
|
3340
|
+
* (domain, entityId) pair in ONE statement per EVENTS_BULK_KEY_CHUNK
|
|
3341
|
+
* keys — the OR-of-ANDs WHERE walks idx_events_domain_entity per term
|
|
3342
|
+
* (never a compound SELECT: the D1 5-term cap rule) — and answers the
|
|
3343
|
+
* MERGED set, seq-ordered, limit-truncated after the merge. */
|
|
3344
|
+
async eventsMatching(filter: EventKeyFilter | { keys: readonly EventEntityKey[] }, limit = 500): Promise<PlatformEvent[]> {
|
|
3345
|
+
if ('keys' in filter) {
|
|
3346
|
+
if (filter.keys.length === 0) return []
|
|
3347
|
+
const merged: PlatformEvent[] = []
|
|
3348
|
+
for (let i = 0; i < filter.keys.length; i += EVENTS_BULK_KEY_CHUNK) {
|
|
3349
|
+
const chunk = filter.keys.slice(i, i + EVENTS_BULK_KEY_CHUNK)
|
|
3350
|
+
const where = chunk.map(() => '(domain = ? AND entity_id = ?)').join(' OR ')
|
|
3351
|
+
const args = chunk.flatMap(k => [k.domain, k.entityId])
|
|
3352
|
+
const res = await this.stmt(
|
|
3353
|
+
`SELECT * FROM events WHERE ${where} ORDER BY seq LIMIT ?`, ...args, limit,
|
|
3354
|
+
).all<Record<string, unknown>>()
|
|
3355
|
+
merged.push(...res.results.map(D1ServerStore.toPlatformEvent))
|
|
3356
|
+
}
|
|
3357
|
+
merged.sort((a, b) => a.seq - b.seq)
|
|
3358
|
+
return merged.slice(0, limit)
|
|
3359
|
+
}
|
|
3327
3360
|
const where: string[] = []
|
|
3328
3361
|
const args: unknown[] = []
|
|
3329
3362
|
if (filter.domain !== undefined) { where.push('domain = ?'); args.push(filter.domain) }
|
|
@@ -3664,14 +3697,20 @@ export class D1ServerStore implements ServerStore {
|
|
|
3664
3697
|
async workflowStoreRowCeiling(): Promise<number> {
|
|
3665
3698
|
await this.ensureInstrumentRegistrationSupport()
|
|
3666
3699
|
await this.ensureNotifyDeliverySupport()
|
|
3700
|
+
// NEVER a compound SELECT here: D1 caps a compound's term count at
|
|
3701
|
+
// 5 (stock SQLite allows 500 — measured on the live fleet
|
|
3702
|
+
// 2026-09-04: five terms answer, six fail with "too many terms in
|
|
3703
|
+
// compound SELECT"). The six-table UNION ALL form this replaces
|
|
3704
|
+
// crossed that cap the night the demo hub's redeploy brought it
|
|
3705
|
+
// (the 2026-09-03/04 demo-reset reds: the reset phase's FIRST read
|
|
3706
|
+
// died, slice 1 answered 400, the wipe never started). The scalar
|
|
3707
|
+
// max() over per-table scalar subqueries keeps ONE round-trip with
|
|
3708
|
+
// a term count of one, and the table list derives from WIPE_TABLES
|
|
3709
|
+
// — a wipe-set addition can never again grow the statement past
|
|
3710
|
+
// the cap by hand. COALESCE keeps an empty table at 0 (the scalar
|
|
3711
|
+
// max() answers NULL on ANY NULL argument).
|
|
3667
3712
|
const row = await this.stmt(
|
|
3668
|
-
`SELECT MAX(
|
|
3669
|
-
SELECT MAX(rowid) AS ceiling FROM entity_changes
|
|
3670
|
-
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
3671
|
-
UNION ALL SELECT MAX(rowid) FROM entities
|
|
3672
|
-
UNION ALL SELECT MAX(rowid) FROM events
|
|
3673
|
-
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
3674
|
-
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
3713
|
+
`SELECT MAX(${WIPE_TABLES.map(t => `COALESCE((SELECT MAX(rowid) FROM ${t}), 0)`).join(', ')}) AS ceiling`,
|
|
3675
3714
|
).first<{ ceiling: number | null }>()
|
|
3676
3715
|
return row?.ceiling ?? 0
|
|
3677
3716
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// ═══════════════════════════════════════════════════════════════════
|
|
10
10
|
|
|
11
11
|
import { getDb } from './store'
|
|
12
|
-
import type
|
|
12
|
+
import { EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type PlatformEvent } from '../../store'
|
|
13
13
|
|
|
14
14
|
interface EventRow {
|
|
15
15
|
seq: number
|
|
@@ -40,12 +40,12 @@ export function appendEvent(input: {
|
|
|
40
40
|
action: string
|
|
41
41
|
payload: string
|
|
42
42
|
}): PlatformEvent {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?)`,
|
|
46
|
-
).run(input.id, input.domain, input.entityId, input.action, input.payload)
|
|
43
|
+
// ONE statement: RETURNING answers the stored row (seq + the default
|
|
44
|
+
// at) off the INSERT itself — the same halving as the D1 half.
|
|
47
45
|
return toPlatformEvent(
|
|
48
|
-
|
|
46
|
+
getDb().prepare(
|
|
47
|
+
`INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *`,
|
|
48
|
+
).get(input.id, input.domain, input.entityId, input.action, input.payload) as EventRow,
|
|
49
49
|
)
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -69,8 +69,28 @@ export function getEvent(id: string): PlatformEvent | null {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
72
|
-
* by equality; the free legs stay out of the WHERE.
|
|
73
|
-
|
|
72
|
+
* by equality; the free legs stay out of the WHERE. The BULK form (the
|
|
73
|
+
* 2026-09-06 audit's notify-inbox seam) resolves every pinned (domain,
|
|
74
|
+
* entityId) pair in ONE statement per EVENTS_BULK_KEY_CHUNK keys — the
|
|
75
|
+
* same chunking as the D1 half, so the backends stay answer-identical —
|
|
76
|
+
* and answers the MERGED set, seq-ordered, limit-truncated after the
|
|
77
|
+
* merge. */
|
|
78
|
+
export function eventsMatching(filter: EventKeyFilter | { keys: readonly EventEntityKey[] }, limit = 500): PlatformEvent[] {
|
|
79
|
+
if ('keys' in filter) {
|
|
80
|
+
if (filter.keys.length === 0) return []
|
|
81
|
+
const merged: PlatformEvent[] = []
|
|
82
|
+
for (let i = 0; i < filter.keys.length; i += EVENTS_BULK_KEY_CHUNK) {
|
|
83
|
+
const chunk = filter.keys.slice(i, i + EVENTS_BULK_KEY_CHUNK)
|
|
84
|
+
const where = chunk.map(() => '(domain = ? AND entity_id = ?)').join(' OR ')
|
|
85
|
+
const args = chunk.flatMap(k => [k.domain, k.entityId])
|
|
86
|
+
const rows = getDb()
|
|
87
|
+
.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq LIMIT ?`)
|
|
88
|
+
.all(...args, limit) as EventRow[]
|
|
89
|
+
merged.push(...rows.map(toPlatformEvent))
|
|
90
|
+
}
|
|
91
|
+
merged.sort((a, b) => a.seq - b.seq)
|
|
92
|
+
return merged.slice(0, limit)
|
|
93
|
+
}
|
|
74
94
|
const where: string[] = []
|
|
75
95
|
const args: unknown[] = []
|
|
76
96
|
if (filter.domain !== undefined) { where.push('domain = ?'); args.push(filter.domain) }
|
|
@@ -406,22 +406,27 @@ export function eraseOpAccount(userId: string): {
|
|
|
406
406
|
/** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
|
|
407
407
|
* newest auditEvents row whose action is a sign-in ('account.sign_in'
|
|
408
408
|
* — the password login; 'upstream_sign_in' — a linked-provider
|
|
409
|
-
* sign-in) per entity_id (the account id).
|
|
409
|
+
* sign-in) per entity_id (the account id). The typed read (the
|
|
410
|
+
* 2026-09-06 audit): json_extract against idx_entities_store_action —
|
|
411
|
+
* an index walk over the sign-in slice, never the O(journal)
|
|
412
|
+
* data-LIKE scan; the json_valid guard spells the index's expression
|
|
413
|
+
* exactly (a corrupt entities row answers NULL legs, never a raised
|
|
414
|
+
* 'malformed JSON'). The action match stays exact (the retired LIKE's
|
|
415
|
+
* closing quote made it so too) — and now spelling-proof: the legs
|
|
416
|
+
* parse the JSON, so a serialized-with-spaces row answers and an
|
|
417
|
+
* embedded lookalike substring never does. */
|
|
410
418
|
export function lastAccountSignIns(): Record<string, string> {
|
|
411
419
|
const rows = getDb().prepare(
|
|
412
|
-
`SELECT data
|
|
420
|
+
`SELECT json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id') AS entity_id,
|
|
421
|
+
MAX(json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.timestamp')) AS ts
|
|
422
|
+
FROM entities
|
|
413
423
|
WHERE store = 'auditEvents'
|
|
414
|
-
AND (data
|
|
415
|
-
|
|
424
|
+
AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.action') IN ('account.sign_in', 'upstream_sign_in')
|
|
425
|
+
GROUP BY json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id')`,
|
|
426
|
+
).all() as Array<{ entity_id: string | null; ts: string | null }>
|
|
416
427
|
const out: Record<string, string> = {}
|
|
417
|
-
for (const {
|
|
418
|
-
|
|
419
|
-
const event = JSON.parse(data) as { entity_id?: string; timestamp?: string }
|
|
420
|
-
if (typeof event.entity_id !== 'string' || typeof event.timestamp !== 'string') continue
|
|
421
|
-
if (!out[event.entity_id] || event.timestamp > out[event.entity_id]!) {
|
|
422
|
-
out[event.entity_id] = event.timestamp
|
|
423
|
-
}
|
|
424
|
-
} catch { /* a malformed audit row is skipped, never trusted */ }
|
|
428
|
+
for (const { entity_id, ts } of rows) {
|
|
429
|
+
if (typeof entity_id === 'string' && typeof ts === 'string') out[entity_id] = ts
|
|
425
430
|
}
|
|
426
431
|
return out
|
|
427
432
|
}
|
|
@@ -110,6 +110,18 @@ CREATE TABLE IF NOT EXISTS entities (
|
|
|
110
110
|
PRIMARY KEY (store, id)
|
|
111
111
|
);
|
|
112
112
|
CREATE INDEX IF NOT EXISTS idx_entities_store_org ON entities (store, org_id);
|
|
113
|
+
-- The audit chain's typed legs (the 2026-09-06 audit's sign-in recency
|
|
114
|
+
-- read — migration 0023): (store, action, entity_id, timestamp) out of
|
|
115
|
+
-- the data JSON, so the per-account last-sign-in read walks the
|
|
116
|
+
-- sign-in slice instead of string-matching the journal. The json_valid
|
|
117
|
+
-- guard keeps a corrupt entities row writable (an unguarded
|
|
118
|
+
-- json_extract index expression would raise on the INSERT).
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_entities_store_action ON entities (
|
|
120
|
+
store,
|
|
121
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.action'),
|
|
122
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.entity_id'),
|
|
123
|
+
json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.timestamp')
|
|
124
|
+
);
|
|
113
125
|
|
|
114
126
|
-- The change journal: every write appends (seq, store, type, id) —
|
|
115
127
|
-- the SSE stream tails it (each client filters its stores).
|
package/src/store/sqlite.ts
CHANGED
|
@@ -179,7 +179,7 @@ import {
|
|
|
179
179
|
updateOpAccount,
|
|
180
180
|
updateUserName,
|
|
181
181
|
} from './sqlite/op-accounts-store'
|
|
182
|
-
import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, 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 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'
|
|
182
|
+
import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, 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 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'
|
|
183
183
|
import {
|
|
184
184
|
advanceWebauthnCounter,
|
|
185
185
|
consumeMfaPending,
|
|
@@ -1008,7 +1008,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
1008
1008
|
async getEvent(id: string): Promise<PlatformEvent | null> {
|
|
1009
1009
|
return getEvent(id)
|
|
1010
1010
|
},
|
|
1011
|
-
async eventsMatching(filter: EventKeyFilter, limit = 500): Promise<PlatformEvent[]> {
|
|
1011
|
+
async eventsMatching(filter: EventKeyFilter | { keys: readonly EventEntityKey[] }, limit = 500): Promise<PlatformEvent[]> {
|
|
1012
1012
|
return eventsMatching(filter, limit)
|
|
1013
1013
|
},
|
|
1014
1014
|
|
|
@@ -1111,14 +1111,13 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
1111
1111
|
return rows
|
|
1112
1112
|
},
|
|
1113
1113
|
async workflowStoreRowCeiling(): Promise<number> {
|
|
1114
|
+
// The D1 half's twin (one rule, two backends): the scalar max()
|
|
1115
|
+
// over per-table scalar subqueries, NEVER a compound SELECT — D1
|
|
1116
|
+
// caps a compound's term count at 5 (the 2026-09 demo-reset
|
|
1117
|
+
// wall), and the table list derives from WIPE_TABLES so the
|
|
1118
|
+
// wipe set and the ceiling set never drift apart by hand.
|
|
1114
1119
|
const row = getDb().prepare(
|
|
1115
|
-
`SELECT MAX(
|
|
1116
|
-
SELECT MAX(rowid) AS ceiling FROM entity_changes
|
|
1117
|
-
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
1118
|
-
UNION ALL SELECT MAX(rowid) FROM entities
|
|
1119
|
-
UNION ALL SELECT MAX(rowid) FROM events
|
|
1120
|
-
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
1121
|
-
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
1120
|
+
`SELECT MAX(${WIPE_TABLES.map(t => `COALESCE((SELECT MAX(rowid) FROM ${t}), 0)`).join(', ')}) AS ceiling`,
|
|
1122
1121
|
).get() as { ceiling: number | null }
|
|
1123
1122
|
return row.ceiling ?? 0
|
|
1124
1123
|
},
|
package/src/store.ts
CHANGED
|
@@ -200,6 +200,22 @@ export interface EventKeyFilter {
|
|
|
200
200
|
action?: string
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** One pinned (domain, entityId) pair of the bulk history read — the
|
|
204
|
+
* participant-history shape (an entity's own event journal), never a
|
|
205
|
+
* partial pattern: both legs are pinned, so every key resolves against
|
|
206
|
+
* idx_events_domain_entity. */
|
|
207
|
+
export interface EventEntityKey {
|
|
208
|
+
domain: string
|
|
209
|
+
entityId: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The bulk read's statement chunk: D1 caps a statement at 100 bound
|
|
213
|
+
* parameters (two per key + the limit), so a bulk call issues
|
|
214
|
+
* ceil(keys / 49) statements — ONE for any realistic inbox window —
|
|
215
|
+
* never one per key. The SQLite half chunks identically so the two
|
|
216
|
+
* backends stay answer-identical. */
|
|
217
|
+
export const EVENTS_BULK_KEY_CHUNK = 49
|
|
218
|
+
|
|
203
219
|
// ── the notification subscriptions store (TODO.notify/02) ────────────
|
|
204
220
|
|
|
205
221
|
/** The rule row's mode: 'subscribe' adds the user to the candidates of
|
|
@@ -1741,7 +1757,13 @@ export interface ServerStore {
|
|
|
1741
1757
|
* the latest auditEvents row whose action is a sign-in
|
|
1742
1758
|
* ('account.sign_in' — the password login; 'upstream_sign_in' — a
|
|
1743
1759
|
* linked-provider sign-in) per entity_id. Answers userId → ISO
|
|
1744
|
-
* timestamp; accounts that never signed in are absent.
|
|
1760
|
+
* timestamp; accounts that never signed in are absent. The typed
|
|
1761
|
+
* read: json_extract legs (json_valid-guarded, the index's exact
|
|
1762
|
+
* spelling) against idx_entities_store_action (migration 0023) — an
|
|
1763
|
+
* index walk over the sign-in slice, never a data-LIKE scan of the
|
|
1764
|
+
* journal. The action match stays exact (the retired LIKE's closing
|
|
1765
|
+
* quote made it so too) and becomes spelling-proof: the legs parse
|
|
1766
|
+
* the JSON. */
|
|
1745
1767
|
lastAccountSignIns(): Promise<Record<string, string>>
|
|
1746
1768
|
|
|
1747
1769
|
// ── the account console (TODO.identity/06) ──
|
|
@@ -2204,12 +2226,28 @@ export interface ServerStore {
|
|
|
2204
2226
|
putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
|
|
2205
2227
|
deleteEntity(store: string, id: string): Promise<boolean>
|
|
2206
2228
|
changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
|
|
2229
|
+
/** The GLOBAL journal high-water: the bootstrap snapshot's ETag leg
|
|
2230
|
+
* (any write anywhere bumps the one seq — conservative, never
|
|
2231
|
+
* stale).
|
|
2232
|
+
*
|
|
2233
|
+
* NAMED FOLLOW-UP, DOC ONLY (the smart side references it from
|
|
2234
|
+
* server/routes/bootstrap.ts: the per-store `seqs` snapshot carries
|
|
2235
|
+
* the journal position a delta-loading client would resume from):
|
|
2236
|
+
* the PER-STORE high-water — `latestChangeSeqFor(store)` — answering
|
|
2237
|
+
* MAX(seq) over one store's slice of the SAME journal. The contract
|
|
2238
|
+
* when it lands: the seq stays global and monotone (the per-store
|
|
2239
|
+
* read is a PROJECTION of the one journal, never a second
|
|
2240
|
+
* sequence); it exists so a per-store conditional revalidation /
|
|
2241
|
+
* SSE resume probe costs one indexed read instead of a payload
|
|
2242
|
+
* fetch; and its query wants the (store, seq) walk — the index
|
|
2243
|
+
* rides the same commit, expand-only per the migration contract. */
|
|
2207
2244
|
latestChangeSeq(): Promise<number>
|
|
2208
2245
|
|
|
2209
2246
|
// ── the platform event store (TODO.notify/01) ──
|
|
2210
2247
|
/** Append one declared event (the emitter's write; one row inside the
|
|
2211
2248
|
* acting request's envelope). Answers the stored row (seq + at read
|
|
2212
|
-
* back)
|
|
2249
|
+
* back) off the INSERT's own RETURNING — ONE round trip, never the
|
|
2250
|
+
* INSERT + SELECT-by-id pair. */
|
|
2213
2251
|
appendEvent(input: {
|
|
2214
2252
|
id: string
|
|
2215
2253
|
domain: string
|
|
@@ -2229,6 +2267,20 @@ export interface ServerStore {
|
|
|
2229
2267
|
* pins, equality-matched (`WHERE domain = ? AND entity_id = ?` — the
|
|
2230
2268
|
* column split's whole point, never a string scan on a composed key). */
|
|
2231
2269
|
eventsMatching(filter: EventKeyFilter, limit?: number): Promise<PlatformEvent[]>
|
|
2270
|
+
/** The BULK history read (the 2026-09-06 performance audit's
|
|
2271
|
+
* notify-inbox seam): the participant-history class awaits the
|
|
2272
|
+
* single-key form once per DISTINCT entity — ~100 D1 round trips for
|
|
2273
|
+
* a 500-event window. The bulk form resolves every pinned (domain,
|
|
2274
|
+
* entityId) pair in ONE statement per chunk of EVENTS_BULK_KEY_CHUNK
|
|
2275
|
+
* (the OR-of-ANDs WHERE walks idx_events_domain_entity per term;
|
|
2276
|
+
* never a compound SELECT — the D1 5-term cap rule).
|
|
2277
|
+
*
|
|
2278
|
+
* The ordering contract: the answer is the MERGED matched set,
|
|
2279
|
+
* seq-ordered (never grouped per key); `limit` truncates the merged
|
|
2280
|
+
* set exactly as the single-key form truncates its own; duplicate
|
|
2281
|
+
* keys match their rows once (the OR is a set union, not a concat);
|
|
2282
|
+
* an empty keys list answers [] without issuing a statement. */
|
|
2283
|
+
eventsMatching(filter: { keys: readonly EventEntityKey[] }, limit?: number): Promise<PlatformEvent[]>
|
|
2232
2284
|
|
|
2233
2285
|
// ── the notification subscriptions store (TODO.notify/02) ──
|
|
2234
2286
|
/** The user's own rule rows (both modes) — the settings page's list. */
|
|
@@ -2333,7 +2385,10 @@ export interface ServerStore {
|
|
|
2333
2385
|
wipeWorkflowStores(range?: { after: number; through: number }): Promise<number>
|
|
2334
2386
|
/** The rowid ceiling across the wiped tables — the reset phase's
|
|
2335
2387
|
* round planning (rounds = ceil(ceiling / chunk)). 0 on empty
|
|
2336
|
-
* stores.
|
|
2388
|
+
* stores. One statement, NEVER a compound SELECT: D1 caps a
|
|
2389
|
+
* compound's terms at 5 (the 2026-09 demo-reset wall), so the
|
|
2390
|
+
* implementations read per-table scalar subqueries under the scalar
|
|
2391
|
+
* max(), derived from the wipe's own table set. */
|
|
2337
2392
|
workflowStoreRowCeiling(): Promise<number>
|
|
2338
2393
|
countEntities(): Promise<number>
|
|
2339
2394
|
}
|