@oimlsmart/platform-server 0.2.11 → 0.2.13
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/LICENSE +21 -0
- package/migrations/0027_notify_mentions_saved.sql +33 -0
- package/migrations/0028_entities_certificate_number_index.sql +22 -0
- package/package.json +1 -1
- package/src/store/d1.ts +91 -4
- package/src/store/sqlite/entities.ts +53 -1
- package/src/store/sqlite/events.ts +35 -8
- package/src/store/sqlite/notify.ts +7 -0
- package/src/store/sqlite/schema.sql +21 -2
- package/src/store/sqlite/store.ts +12 -0
- package/src/store/sqlite.ts +13 -1
- package/src/store.ts +83 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OIML SMART project contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- TODO.notify/05's remainders (platform-server#25 — the smart side's
|
|
2
|
+
-- TODO.notify/00 banner: the mentions-parse half and the per-event saved
|
|
3
|
+
-- flag ride a KERNEL migration). Expand-only per the migration contract
|
|
4
|
+
-- (append, never renumber): two columns, both NULL-defaulted, existing
|
|
5
|
+
-- rows untouched.
|
|
6
|
+
--
|
|
7
|
+
-- events.mentions the act's @user mentions (TODO.notify/00's
|
|
8
|
+
-- "Mentions land as the 'mentioned' reason"):
|
|
9
|
+
-- a JSON array of the mentioned USER IDS,
|
|
10
|
+
-- resolved by the comment surfaces' parse at
|
|
11
|
+
-- event time and recorded ON THE ROW — the
|
|
12
|
+
-- inbox feed computes its reasons at READ,
|
|
13
|
+
-- and a mention is a fact of the act, never
|
|
14
|
+
-- re-derivable from the entity. NULL = none
|
|
15
|
+
-- (every pre-0027 row, every mention-free
|
|
16
|
+
-- act). The recipient resolution's
|
|
17
|
+
-- 'mentioned' candidate class reads it; the
|
|
18
|
+
-- mutes and the visibility gate still win
|
|
19
|
+
-- (a mention never widens access).
|
|
20
|
+
--
|
|
21
|
+
-- notify_inbox_state.saved_at the per-event saved flag (TODO.notify/
|
|
22
|
+
-- 00's inbox state "read / done / saved",
|
|
23
|
+
-- the GitHub Save): stamps at the save act,
|
|
24
|
+
-- clears at the un-save. Independent of
|
|
25
|
+
-- read/done — a saved row that is done stays
|
|
26
|
+
-- saved in the archive. The marker row's
|
|
27
|
+
-- posture is unchanged (the user's own state,
|
|
28
|
+
-- lazily written, no foreign keys).
|
|
29
|
+
--
|
|
30
|
+
-- The schema's SQLite mirror (src/store/sqlite/schema.sql) updates in
|
|
31
|
+
-- the same commit; test/migrations.test.ts fails the package otherwise.
|
|
32
|
+
ALTER TABLE events ADD COLUMN mentions TEXT;
|
|
33
|
+
ALTER TABLE notify_inbox_state ADD COLUMN saved_at TEXT;
|
|
@@ -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.13",
|
|
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,
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
type InstrumentRegistrationLifecycle,
|
|
89
90
|
type InstrumentRegistrationScopeStatus,
|
|
90
91
|
type InstrumentRegistrationWriteInput,
|
|
92
|
+
type JournalAppend,
|
|
91
93
|
type PersonalAccessToken,
|
|
92
94
|
type PlatformEvent,
|
|
93
95
|
resolveOrgContext,
|
|
@@ -427,6 +429,43 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
|
|
|
427
429
|
ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
|
|
428
430
|
const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
|
|
429
431
|
|
|
432
|
+
/** The journal fan-out's ISOLATE-scope registry (the seam's
|
|
433
|
+
* onJournalAppend): one module-scope set — a registration through ANY
|
|
434
|
+
* D1ServerStore instance hears every journal append landing in this
|
|
435
|
+
* isolate, whichever instance wrote it. Cross-isolate writes never
|
|
436
|
+
* fire it (the database is the source of truth; the consumer's poll
|
|
437
|
+
* stays the fallback). */
|
|
438
|
+
const journalListeners = new Set<(appends: readonly JournalAppend[]) => void>()
|
|
439
|
+
|
|
440
|
+
/** Fires the registry with one write's appended triples, AFTER the
|
|
441
|
+
* write stands (the batch resolved / the row gone). Synchronous, in
|
|
442
|
+
* the write's continuation; a listener's throw is swallowed per
|
|
443
|
+
* listener — the write path never breaks for a listener. The snapshot
|
|
444
|
+
* keeps an unregistering listener's removal honest mid-emit. */
|
|
445
|
+
function emitJournalAppends(appends: readonly JournalAppend[]): void {
|
|
446
|
+
if (appends.length === 0 || journalListeners.size === 0) return
|
|
447
|
+
for (const listener of [...journalListeners]) {
|
|
448
|
+
try {
|
|
449
|
+
listener(appends)
|
|
450
|
+
} catch { /* a listener never breaks the write path */ }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The register's number lookup (findCertificatesByNumber): the keyed
|
|
455
|
+
* read against idx_entities_store_certificate_number (migration 0028)
|
|
456
|
+
* — the json_valid-guarded extract's exact spelling, COLLATE NOCASE
|
|
457
|
+
* for the register's case-insensitive number match. INDEXED BY pins
|
|
458
|
+
* the walk: without it the planner prefers idx_entities_store_org for
|
|
459
|
+
* the ORDER BY (org_id, rowid) — the seam's list order, kept so the
|
|
460
|
+
* first match IS the retiring listEntities scan's first match — and
|
|
461
|
+
* the "index" would walk the whole store; with it a database behind
|
|
462
|
+
* migration 0028 errors honestly, never scans silently. */
|
|
463
|
+
const CERTIFICATE_NUMBER_SQL = `SELECT store, id, org_id, data, updated_at
|
|
464
|
+
FROM entities INDEXED BY idx_entities_store_certificate_number
|
|
465
|
+
WHERE store = 'certificates'
|
|
466
|
+
AND json_extract(CASE WHEN json_valid(data) THEN data ELSE '{}' END, '$.certificate_number') COLLATE NOCASE = ?
|
|
467
|
+
ORDER BY org_id, rowid`
|
|
468
|
+
|
|
430
469
|
/** The register write's INSERT — ONE textual source for
|
|
431
470
|
* createInstrumentRegistration and createInstrumentRegistrations alike
|
|
432
471
|
* (the batch verb appends RETURNING * so the stored row answers off
|
|
@@ -438,8 +477,9 @@ const INSTRUMENT_REGISTRATION_INSERT_SQL = `INSERT OR IGNORE INTO instrument_reg
|
|
|
438
477
|
|
|
439
478
|
/** The event append's INSERT … RETURNING * — ONE textual source for
|
|
440
479
|
* appendEvent and appendEvents alike (the stored row — seq + the
|
|
441
|
-
* default at — answers off the write itself).
|
|
442
|
-
|
|
480
|
+
* default at — answers off the write itself). The mentions column
|
|
481
|
+
* (migration 0027) lands from the input, NULL when absent. */
|
|
482
|
+
const EVENT_INSERT_SQL = 'INSERT INTO events (id, domain, entity_id, action, payload, mentions) VALUES (?, ?, ?, ?, ?, ?) RETURNING *'
|
|
443
483
|
|
|
444
484
|
export class D1ServerStore implements ServerStore {
|
|
445
485
|
/** The RAW binding — the ensure memos (and d1StoreFor's map) key on
|
|
@@ -3524,6 +3564,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
3524
3564
|
return row ?? undefined
|
|
3525
3565
|
}
|
|
3526
3566
|
|
|
3567
|
+
async findCertificatesByNumber(number: string): Promise<EntityRow[]> {
|
|
3568
|
+
const res = await this.stmt(CERTIFICATE_NUMBER_SQL, number).all<EntityRow>()
|
|
3569
|
+
return res.results
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3527
3572
|
async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
|
|
3528
3573
|
// The upsert + its journal entry ride ONE batch — D1 batches are
|
|
3529
3574
|
// all-or-nothing, the same atomicity the SQLite path gets from its
|
|
@@ -3532,6 +3577,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3532
3577
|
this.stmt(ENTITY_UPSERT_SQL, store, id, orgId, data),
|
|
3533
3578
|
this.stmt(ENTITY_CHANGE_SQL, store, 'persist', id),
|
|
3534
3579
|
])
|
|
3580
|
+
emitJournalAppends([{ store, type: 'persist', id }])
|
|
3535
3581
|
}
|
|
3536
3582
|
|
|
3537
3583
|
async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
|
|
@@ -3554,6 +3600,10 @@ export class D1ServerStore implements ServerStore {
|
|
|
3554
3600
|
)
|
|
3555
3601
|
}
|
|
3556
3602
|
await this.db.batch(statements)
|
|
3603
|
+
// The fan-out fires per LANDED chunk (the triples in input order)
|
|
3604
|
+
// — a failed chunk throws before its emit, so a listener never
|
|
3605
|
+
// hears of rows that did not stand.
|
|
3606
|
+
emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
|
|
3557
3607
|
}
|
|
3558
3608
|
}
|
|
3559
3609
|
|
|
@@ -3562,6 +3612,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3562
3612
|
const gone = (res.meta.changes ?? 0) > 0
|
|
3563
3613
|
if (gone) {
|
|
3564
3614
|
await this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'remove', id).run()
|
|
3615
|
+
emitJournalAppends([{ store, type: 'remove', id }])
|
|
3565
3616
|
}
|
|
3566
3617
|
return gone
|
|
3567
3618
|
}
|
|
@@ -3588,6 +3639,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
3588
3639
|
return row?.seq ?? 0
|
|
3589
3640
|
}
|
|
3590
3641
|
|
|
3642
|
+
onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void {
|
|
3643
|
+
journalListeners.add(listener)
|
|
3644
|
+
return () => { journalListeners.delete(listener) }
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3591
3647
|
// ── the platform event store (TODO.notify/01) ─────────────────────
|
|
3592
3648
|
// The event rows port directly (D1 is SQLite) — the same statements
|
|
3593
3649
|
// as sqlite/events.ts's sync half.
|
|
@@ -3600,6 +3656,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3600
3656
|
entityId: row.entity_id as string,
|
|
3601
3657
|
action: row.action as string,
|
|
3602
3658
|
payload: row.payload as string,
|
|
3659
|
+
mentions: (row.mentions as string | null) ?? null,
|
|
3603
3660
|
at: row.at as string,
|
|
3604
3661
|
}
|
|
3605
3662
|
}
|
|
@@ -3611,7 +3668,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3611
3668
|
// two round trips per event, halved).
|
|
3612
3669
|
const row = await this.stmt(
|
|
3613
3670
|
EVENT_INSERT_SQL,
|
|
3614
|
-
input.id, input.domain, input.entityId, input.action, input.payload,
|
|
3671
|
+
input.id, input.domain, input.entityId, input.action, input.payload, input.mentions ?? null,
|
|
3615
3672
|
).first<Record<string, unknown>>()
|
|
3616
3673
|
return D1ServerStore.toPlatformEvent(row!)
|
|
3617
3674
|
}
|
|
@@ -3630,7 +3687,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3630
3687
|
const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
|
|
3631
3688
|
const statements = chunk.map(e => this.stmt(
|
|
3632
3689
|
EVENT_INSERT_SQL,
|
|
3633
|
-
e.id, e.domain, e.entityId, e.action, e.payload,
|
|
3690
|
+
e.id, e.domain, e.entityId, e.action, e.payload, e.mentions ?? null,
|
|
3634
3691
|
))
|
|
3635
3692
|
const results = await this.db.batch(statements)
|
|
3636
3693
|
for (const res of results) {
|
|
@@ -3657,6 +3714,28 @@ export class D1ServerStore implements ServerStore {
|
|
|
3657
3714
|
return row ? D1ServerStore.toPlatformEvent(row) : null
|
|
3658
3715
|
}
|
|
3659
3716
|
|
|
3717
|
+
/** The bulk by-id read (the seam's contract, mechanically): every id
|
|
3718
|
+
* resolves in ONE statement per EVENTS_ID_CHUNK ids — the IN walk
|
|
3719
|
+
* against the id UNIQUE index (never a compound SELECT: the D1
|
|
3720
|
+
* 5-term cap rule), the chunks serial — and the answer is
|
|
3721
|
+
* INPUT-ALIGNED: position i carries the row for ids[i], null where
|
|
3722
|
+
* no event carries the id (the per-id getEvent loop's exact
|
|
3723
|
+
* answers; a duplicate id answers its row at every position). An
|
|
3724
|
+
* empty list answers [] without issuing a statement. */
|
|
3725
|
+
async getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]> {
|
|
3726
|
+
if (ids.length === 0) return []
|
|
3727
|
+
const byId = new Map<string, PlatformEvent>()
|
|
3728
|
+
for (let i = 0; i < ids.length; i += EVENTS_ID_CHUNK) {
|
|
3729
|
+
const chunk = ids.slice(i, i + EVENTS_ID_CHUNK)
|
|
3730
|
+
const marks = chunk.map(() => '?').join(', ')
|
|
3731
|
+
const res = await this.stmt(
|
|
3732
|
+
`SELECT * FROM events WHERE id IN (${marks})`, ...chunk,
|
|
3733
|
+
).all<Record<string, unknown>>()
|
|
3734
|
+
for (const row of res.results) byId.set(row.id as string, D1ServerStore.toPlatformEvent(row))
|
|
3735
|
+
}
|
|
3736
|
+
return ids.map(id => byId.get(id) ?? null)
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3660
3739
|
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
3661
3740
|
* by equality; the free legs stay out of the WHERE. The BULK form
|
|
3662
3741
|
* (the 2026-09-06 audit's notify-inbox seam) resolves every pinned
|
|
@@ -3842,6 +3921,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3842
3921
|
eventId: row.event_id as string,
|
|
3843
3922
|
readAt: (row.read_at as string | null) ?? null,
|
|
3844
3923
|
doneAt: (row.done_at as string | null) ?? null,
|
|
3924
|
+
savedAt: (row.saved_at as string | null) ?? null,
|
|
3845
3925
|
createdAt: row.created_at as string,
|
|
3846
3926
|
}
|
|
3847
3927
|
}
|
|
@@ -3860,6 +3940,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3860
3940
|
eventId: string
|
|
3861
3941
|
read?: boolean
|
|
3862
3942
|
done?: boolean
|
|
3943
|
+
saved?: boolean
|
|
3863
3944
|
}): Promise<NotifyInboxState> {
|
|
3864
3945
|
await this.stmt(
|
|
3865
3946
|
'INSERT OR IGNORE INTO notify_inbox_state (user_id, event_id) VALUES (?, ?)',
|
|
@@ -3877,6 +3958,12 @@ export class D1ServerStore implements ServerStore {
|
|
|
3877
3958
|
input.done ? 1 : 0, input.userId, input.eventId,
|
|
3878
3959
|
).run()
|
|
3879
3960
|
}
|
|
3961
|
+
if (input.saved !== undefined) {
|
|
3962
|
+
await this.stmt(
|
|
3963
|
+
"UPDATE notify_inbox_state SET saved_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
|
|
3964
|
+
input.saved ? 1 : 0, input.userId, input.eventId,
|
|
3965
|
+
).run()
|
|
3966
|
+
}
|
|
3880
3967
|
const row = await this.stmt(
|
|
3881
3968
|
'SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?',
|
|
3882
3969
|
input.userId, input.eventId,
|
|
@@ -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, 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,6 +54,21 @@ 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
|
|
|
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
|
+
|
|
31
72
|
export function listEntities(store: string): 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
|
|
@@ -45,6 +86,12 @@ export function getEntity(store: string, id: string): EntityRow | undefined {
|
|
|
45
86
|
.get(store, id) as EntityRow | undefined
|
|
46
87
|
}
|
|
47
88
|
|
|
89
|
+
/** The register's keyed number lookup — one index walk, never the
|
|
90
|
+
* listEntities scan + per-row JSON parse. */
|
|
91
|
+
export function findCertificatesByNumber(number: string): EntityRow[] {
|
|
92
|
+
return getDb().prepare(CERTIFICATE_NUMBER_SQL).all(number) as EntityRow[]
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
|
|
49
96
|
const db = getDb()
|
|
50
97
|
const write = db.transaction(() => {
|
|
@@ -52,6 +99,7 @@ export function putEntity(store: string, id: string, orgId: string | null, data:
|
|
|
52
99
|
db.prepare(ENTITY_CHANGE_SQL).run(store, 'persist', id)
|
|
53
100
|
})
|
|
54
101
|
write()
|
|
102
|
+
emitJournalAppends([{ store, type: 'persist', id }])
|
|
55
103
|
}
|
|
56
104
|
|
|
57
105
|
/** The multi-row write (the seam's putEntities, the 2026-09-07 audit's
|
|
@@ -74,6 +122,9 @@ export function putEntities(store: string, rows: readonly EntityWriteInput[]): v
|
|
|
74
122
|
journal.run(store, 'persist', row.id)
|
|
75
123
|
}
|
|
76
124
|
})()
|
|
125
|
+
// The fan-out fires per LANDED chunk (the D1 half's posture) — a
|
|
126
|
+
// failed chunk throws before its emit.
|
|
127
|
+
emitJournalAppends(chunk.map((row): JournalAppend => ({ store, type: 'persist', id: row.id })))
|
|
77
128
|
}
|
|
78
129
|
}
|
|
79
130
|
|
|
@@ -88,6 +139,7 @@ export function deleteEntity(store: string, id: string): boolean {
|
|
|
88
139
|
}
|
|
89
140
|
})
|
|
90
141
|
write()
|
|
142
|
+
if (gone) emitJournalAppends([{ store, type: 'remove', id }])
|
|
91
143
|
return gone
|
|
92
144
|
}
|
|
93
145
|
|
|
@@ -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
|
|
@@ -18,6 +18,7 @@ interface EventRow {
|
|
|
18
18
|
entity_id: string
|
|
19
19
|
action: string
|
|
20
20
|
payload: string
|
|
21
|
+
mentions: string | null
|
|
21
22
|
at: string
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -29,17 +30,23 @@ function toPlatformEvent(row: EventRow): PlatformEvent {
|
|
|
29
30
|
entityId: row.entity_id,
|
|
30
31
|
action: row.action,
|
|
31
32
|
payload: row.payload,
|
|
33
|
+
mentions: row.mentions,
|
|
32
34
|
at: row.at,
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/** The event append's INSERT … RETURNING * — ONE textual source for
|
|
39
|
+
* appendEvent and appendEvents alike (the D1 half's EVENT_INSERT_SQL's
|
|
40
|
+
* twin; the stored row answers off the write itself). The mentions
|
|
41
|
+
* column (migration 0027) lands from the input, NULL when absent. */
|
|
42
|
+
const EVENT_INSERT_SQL = 'INSERT INTO events (id, domain, entity_id, action, payload, mentions) VALUES (?, ?, ?, ?, ?, ?) RETURNING *'
|
|
43
|
+
|
|
36
44
|
export function appendEvent(input: EventWriteInput): PlatformEvent {
|
|
37
45
|
// ONE statement: RETURNING answers the stored row (seq + the default
|
|
38
46
|
// at) off the INSERT itself — the same halving as the D1 half.
|
|
39
47
|
return toPlatformEvent(
|
|
40
|
-
getDb().prepare(
|
|
41
|
-
|
|
42
|
-
).get(input.id, input.domain, input.entityId, input.action, input.payload) as EventRow,
|
|
48
|
+
getDb().prepare(EVENT_INSERT_SQL)
|
|
49
|
+
.get(input.id, input.domain, input.entityId, input.action, input.payload, input.mentions ?? null) as EventRow,
|
|
43
50
|
)
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -55,15 +62,13 @@ export function appendEvent(input: EventWriteInput): PlatformEvent {
|
|
|
55
62
|
export function appendEvents(events: readonly EventWriteInput[]): PlatformEvent[] {
|
|
56
63
|
if (events.length === 0) return []
|
|
57
64
|
const db = getDb()
|
|
58
|
-
const insert = db.prepare(
|
|
59
|
-
`INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *`,
|
|
60
|
-
)
|
|
65
|
+
const insert = db.prepare(EVENT_INSERT_SQL)
|
|
61
66
|
const out: PlatformEvent[] = []
|
|
62
67
|
for (let i = 0; i < events.length; i += APPEND_EVENTS_CHUNK) {
|
|
63
68
|
const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
|
|
64
69
|
db.transaction(() => {
|
|
65
70
|
for (const e of chunk) {
|
|
66
|
-
out.push(toPlatformEvent(insert.get(e.id, e.domain, e.entityId, e.action, e.payload) as EventRow))
|
|
71
|
+
out.push(toPlatformEvent(insert.get(e.id, e.domain, e.entityId, e.action, e.payload, e.mentions ?? null) as EventRow))
|
|
67
72
|
}
|
|
68
73
|
})()
|
|
69
74
|
}
|
|
@@ -89,6 +94,28 @@ export function getEvent(id: string): PlatformEvent | null {
|
|
|
89
94
|
return row ? toPlatformEvent(row) : null
|
|
90
95
|
}
|
|
91
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
|
+
|
|
92
119
|
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
93
120
|
* by equality; the free legs stay out of the WHERE. The BULK form (the
|
|
94
121
|
* 2026-09-06 audit's notify-inbox seam) resolves every pinned (domain,
|
|
@@ -205,6 +205,7 @@ interface InboxStateRow {
|
|
|
205
205
|
event_id: string
|
|
206
206
|
read_at: string | null
|
|
207
207
|
done_at: string | null
|
|
208
|
+
saved_at: string | null
|
|
208
209
|
created_at: string
|
|
209
210
|
}
|
|
210
211
|
|
|
@@ -214,6 +215,7 @@ function toNotifyInboxState(row: InboxStateRow): NotifyInboxState {
|
|
|
214
215
|
eventId: row.event_id,
|
|
215
216
|
readAt: row.read_at,
|
|
216
217
|
doneAt: row.done_at,
|
|
218
|
+
savedAt: row.saved_at,
|
|
217
219
|
createdAt: row.created_at,
|
|
218
220
|
}
|
|
219
221
|
}
|
|
@@ -233,6 +235,7 @@ export function putNotifyInboxState(input: {
|
|
|
233
235
|
eventId: string
|
|
234
236
|
read?: boolean
|
|
235
237
|
done?: boolean
|
|
238
|
+
saved?: boolean
|
|
236
239
|
}): NotifyInboxState {
|
|
237
240
|
const db = getDb()
|
|
238
241
|
db.prepare(
|
|
@@ -246,6 +249,10 @@ export function putNotifyInboxState(input: {
|
|
|
246
249
|
db.prepare("UPDATE notify_inbox_state SET done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?")
|
|
247
250
|
.run(input.done ? 1 : 0, input.userId, input.eventId)
|
|
248
251
|
}
|
|
252
|
+
if (input.saved !== undefined) {
|
|
253
|
+
db.prepare("UPDATE notify_inbox_state SET saved_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?")
|
|
254
|
+
.run(input.saved ? 1 : 0, input.userId, input.eventId)
|
|
255
|
+
}
|
|
249
256
|
return toNotifyInboxState(
|
|
250
257
|
db.prepare('SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?')
|
|
251
258
|
.get(input.userId, input.eventId) as InboxStateRow,
|
|
@@ -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).
|
|
@@ -149,7 +158,12 @@ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store
|
|
|
149
158
|
-- envelope (the summary line, the deep link, the actors, the entity's
|
|
150
159
|
-- store for the read-time visibility gate). seq is the feed cursor;
|
|
151
160
|
-- written inside the acting request's envelope, never blocking the
|
|
152
|
-
-- triggering flow (the mailer doctrine).
|
|
161
|
+
-- triggering flow (the mailer doctrine). mentions (migration 0027,
|
|
162
|
+
-- TODO.notify/05's remainders) is the act's @user mentions as a JSON
|
|
163
|
+
-- array of user ids, resolved by the comment surfaces' parse at event
|
|
164
|
+
-- time and recorded ON THE ROW — a mention is a fact of the act, never
|
|
165
|
+
-- re-derivable from the entity at the feed's read-time computation.
|
|
166
|
+
-- NULL = none.
|
|
153
167
|
CREATE TABLE IF NOT EXISTS events (
|
|
154
168
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
169
|
id TEXT UNIQUE NOT NULL,
|
|
@@ -157,6 +171,7 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
157
171
|
entity_id TEXT NOT NULL,
|
|
158
172
|
action TEXT NOT NULL,
|
|
159
173
|
payload TEXT NOT NULL,
|
|
174
|
+
mentions TEXT,
|
|
160
175
|
at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
161
176
|
);
|
|
162
177
|
CREATE INDEX IF NOT EXISTS idx_events_domain_entity ON events (domain, entity_id);
|
|
@@ -219,7 +234,10 @@ CREATE TABLE IF NOT EXISTS notify_preferences (
|
|
|
219
234
|
-- TODO.notify/03 — the inbox state: the per-user per-event read markers
|
|
220
235
|
-- (migration 0014). One row per (user, event), written lazily at the
|
|
221
236
|
-- first act on the inbox row: read_at stamps the mark-read, done_at the
|
|
222
|
-
-- archive (a done row leaves the feed; the marker keeps the state)
|
|
237
|
+
-- archive (a done row leaves the feed; the marker keeps the state),
|
|
238
|
+
-- saved_at the per-event saved flag (migration 0027, TODO.notify/05's
|
|
239
|
+
-- remainders — the GitHub Save, independent of read/done: a saved row
|
|
240
|
+
-- that is done stays saved in the archive). The
|
|
223
241
|
-- feed itself is COMPUTED at read (events × the user's rules × the
|
|
224
242
|
-- visibility gate) — this table is the state the computation joins, and
|
|
225
243
|
-- a marker on a wiped event simply never joins (the user's state is
|
|
@@ -233,6 +251,7 @@ CREATE TABLE IF NOT EXISTS notify_inbox_state (
|
|
|
233
251
|
event_id TEXT NOT NULL,
|
|
234
252
|
read_at TEXT,
|
|
235
253
|
done_at TEXT,
|
|
254
|
+
saved_at TEXT,
|
|
236
255
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
237
256
|
PRIMARY KEY (user_id, event_id)
|
|
238
257
|
);
|
|
@@ -154,6 +154,18 @@ function migrateAuthTables(db: Database.Database): void {
|
|
|
154
154
|
if (emailChangeCols.length && !emailChangeCols.some(c => c.name === 'kind')) {
|
|
155
155
|
db.exec("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'")
|
|
156
156
|
}
|
|
157
|
+
// TODO.notify/05's remainders (migration 0027): the event row's
|
|
158
|
+
// mentions column + the inbox marker's saved_at stamp — a dev file
|
|
159
|
+
// predating it grows the columns here. NULL = none / unsaved: existing
|
|
160
|
+
// rows keep their posture silently.
|
|
161
|
+
const eventCols = db.prepare('PRAGMA table_info(events)').all() as Array<{ name: string }>
|
|
162
|
+
if (eventCols.length && !eventCols.some(c => c.name === 'mentions')) {
|
|
163
|
+
db.exec('ALTER TABLE events ADD COLUMN mentions TEXT')
|
|
164
|
+
}
|
|
165
|
+
const inboxCols = db.prepare('PRAGMA table_info(notify_inbox_state)').all() as Array<{ name: string }>
|
|
166
|
+
if (inboxCols.length && !inboxCols.some(c => c.name === 'saved_at')) {
|
|
167
|
+
db.exec('ALTER TABLE notify_inbox_state ADD COLUMN saved_at TEXT')
|
|
168
|
+
}
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
// AuthUserPayload lives in ./backend (see the re-export above).
|
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 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,
|
|
@@ -1018,6 +1021,9 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
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
|
@@ -175,6 +175,16 @@ export interface EntityChange {
|
|
|
175
175
|
at: string
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/** The journal fan-out's payload (onJournalAppend): the (store, type,
|
|
179
|
+
* id) triple one entity_changes row carries. seq/at are the
|
|
180
|
+
* database's (the write path never reads them back) — the consumer
|
|
181
|
+
* re-reads via changesAfter from its own cursor. */
|
|
182
|
+
export interface JournalAppend {
|
|
183
|
+
store: string
|
|
184
|
+
type: 'persist' | 'remove'
|
|
185
|
+
id: string
|
|
186
|
+
}
|
|
187
|
+
|
|
178
188
|
/** One row of the multi-row write (putEntities): putEntity's (id,
|
|
179
189
|
* orgId, data) legs — the store is the call's, once for the whole
|
|
180
190
|
* batch. */
|
|
@@ -206,7 +216,12 @@ export const PUT_ENTITIES_CHUNK = 50
|
|
|
206
216
|
* composed key is derived at read, never stored. `payload` is the JSON
|
|
207
217
|
* envelope the catalog row declares (the summary line, the deep link,
|
|
208
218
|
* the actors, the entity's store for the read-time visibility gate).
|
|
209
|
-
* `
|
|
219
|
+
* `mentions` (migration 0027, TODO.notify/05's remainders) is the act's
|
|
220
|
+
* @user mentions as a JSON array of user ids — the comment surfaces'
|
|
221
|
+
* parse resolves them at event time and records them ON THE ROW (a
|
|
222
|
+
* mention is a fact of the act; the feed's read-time reason computation
|
|
223
|
+
* could never re-derive it from the entity). NULL = none. `seq` is the
|
|
224
|
+
* feed cursor (the entity_changes journal's pattern). */
|
|
210
225
|
export interface PlatformEvent {
|
|
211
226
|
seq: number
|
|
212
227
|
id: string
|
|
@@ -214,6 +229,9 @@ export interface PlatformEvent {
|
|
|
214
229
|
entityId: string
|
|
215
230
|
action: string
|
|
216
231
|
payload: string
|
|
232
|
+
/** The mentioned user ids' JSON array (raw, the payload/channelOverrides
|
|
233
|
+
* posture — the consumer parses); NULL = no mentions. */
|
|
234
|
+
mentions: string | null
|
|
217
235
|
at: string
|
|
218
236
|
}
|
|
219
237
|
|
|
@@ -226,6 +244,9 @@ export interface EventWriteInput {
|
|
|
226
244
|
entityId: string
|
|
227
245
|
action: string
|
|
228
246
|
payload: string
|
|
247
|
+
/** The act's mentioned user ids (JSON array; TODO.notify/05's
|
|
248
|
+
* remainders) — absent/NULL lands NULL (no mentions). */
|
|
249
|
+
mentions?: string | null
|
|
229
250
|
}
|
|
230
251
|
|
|
231
252
|
/** The key pattern's SQL legs: a subscription pattern (`application/**`,
|
|
@@ -254,6 +275,14 @@ export interface EventEntityKey {
|
|
|
254
275
|
* backends stay answer-identical. */
|
|
255
276
|
export const EVENTS_BULK_KEY_CHUNK = 49
|
|
256
277
|
|
|
278
|
+
/** The bulk by-id read's statement chunk (getEvents): D1 caps a
|
|
279
|
+
* statement at 100 bound parameters and the IN list binds ONE per id
|
|
280
|
+
* (no limit parameter — the answer size is the matched-id count), so
|
|
281
|
+
* a bulk call issues ceil(ids / 99) statements — ONE for any
|
|
282
|
+
* realistic digest window — never one per id. The SQLite half chunks
|
|
283
|
+
* identically so the two backends stay answer-identical. */
|
|
284
|
+
export const EVENTS_ID_CHUNK = 99
|
|
285
|
+
|
|
257
286
|
/** The bulk append's chunk, in ROWS (appendEvents): each event
|
|
258
287
|
* contributes ONE statement (the INSERT … RETURNING * answers the
|
|
259
288
|
* stored row off the write itself, the appendEvent halving), so a
|
|
@@ -330,7 +359,10 @@ export interface NotifyPreferences {
|
|
|
330
359
|
* lazily at read"): one row per (user, event), created at the first act
|
|
331
360
|
* on the inbox row. `readAt` stamps the mark-read (NULL = unread);
|
|
332
361
|
* `doneAt` stamps the archive (NULL = in the inbox — a done row leaves
|
|
333
|
-
* the feed, the marker keeps the state)
|
|
362
|
+
* the feed, the marker keeps the state); `savedAt` (migration 0027,
|
|
363
|
+
* TODO.notify/05's remainders) stamps the per-event saved flag — the
|
|
364
|
+
* GitHub Save, independent of read/done (a saved row that is done stays
|
|
365
|
+
* saved in the archive).
|
|
334
366
|
* The feed is COMPUTED at read (events × the user's rules × the
|
|
335
367
|
* visibility gate); this table is the state that computation joins. A
|
|
336
368
|
* marker on a wiped event never joins (the user's state is their own,
|
|
@@ -340,6 +372,7 @@ export interface NotifyInboxState {
|
|
|
340
372
|
eventId: string
|
|
341
373
|
readAt: string | null
|
|
342
374
|
doneAt: string | null
|
|
375
|
+
savedAt: string | null
|
|
343
376
|
createdAt: string
|
|
344
377
|
}
|
|
345
378
|
|
|
@@ -2455,6 +2488,22 @@ export interface ServerStore {
|
|
|
2455
2488
|
* migration 0023's expression index flipped the unnamed walk). */
|
|
2456
2489
|
listEntities(store: string): Promise<EntityRow[]>
|
|
2457
2490
|
getEntity(store: string, id: string): Promise<EntityRow | undefined>
|
|
2491
|
+
/** The public register's certificate-number lookup (the verify
|
|
2492
|
+
* surfaces' keyed read — the 2026-09-07 performance audit's
|
|
2493
|
+
* public-lookup seam): the register resolved a number by
|
|
2494
|
+
* full-scanning listEntities('certificates') and JSON-parsing every
|
|
2495
|
+
* row to case-fold certificate_number — O(store rows) per lookup.
|
|
2496
|
+
* The keyed read walks idx_entities_store_certificate_number
|
|
2497
|
+
* (migration 0028): the json_valid-guarded extract, COLLATE NOCASE
|
|
2498
|
+
* (the register's number match is case-insensitive; the fold is
|
|
2499
|
+
* ASCII, the number grammar's alphabet). Answers every match in the
|
|
2500
|
+
* seam's list order (org_id, rowid), so the first match IS the
|
|
2501
|
+
* retiring scan's first match. The statement INDEXED BY-pins the
|
|
2502
|
+
* walk: a database behind migration 0028 errors honestly, never
|
|
2503
|
+
* scans silently. The hardcoded store name follows the
|
|
2504
|
+
* lastAccountSignIns 'auditEvents' precedent — the read's semantics
|
|
2505
|
+
* ARE the consumer's data convention. */
|
|
2506
|
+
findCertificatesByNumber(number: string): Promise<EntityRow[]>
|
|
2458
2507
|
putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
|
|
2459
2508
|
/** The MULTI-ROW write (the 2026-09-07 performance audit's J1 — the
|
|
2460
2509
|
* CSV registration commit awaited putEntity per imported row, ~10⁴
|
|
@@ -2501,6 +2550,21 @@ export interface ServerStore {
|
|
|
2501
2550
|
* audit's G1: the smart side's bootstrap composite ETag switches
|
|
2502
2551
|
* from the global seq to the per-set composite of these. */
|
|
2503
2552
|
latestChangeSeqFor(store: string): Promise<number>
|
|
2553
|
+
/** The journal fan-out (the SSE stream's true-push wake): registers a
|
|
2554
|
+
* listener on the ISOLATE-scope registry — every entity_changes
|
|
2555
|
+
* append landing in THIS isolate (putEntity's entry, each putEntities
|
|
2556
|
+
* chunk's entries as the chunk lands, deleteEntity's remove) fires it
|
|
2557
|
+
* synchronously AFTER the write stands, with the appended (store,
|
|
2558
|
+
* type, id) triples in the write's input order. The answer is the
|
|
2559
|
+
* unregister (idempotent).
|
|
2560
|
+
*
|
|
2561
|
+
* The honesty boundary: the journal's source of truth is the
|
|
2562
|
+
* DATABASE — a write landing in ANOTHER isolate (a sibling Worker)
|
|
2563
|
+
* never fires this isolate's listeners, so a consumer keeps its poll
|
|
2564
|
+
* as the fallback and treats the fan-out as a wake-up hint, never a
|
|
2565
|
+
* correctness channel. A listener's throw is swallowed per listener:
|
|
2566
|
+
* the write path never breaks for a listener. */
|
|
2567
|
+
onJournalAppend(listener: (appends: readonly JournalAppend[]) => void): () => void
|
|
2504
2568
|
|
|
2505
2569
|
// ── the platform event store (TODO.notify/01) ──
|
|
2506
2570
|
/** Append one declared event (the emitter's write; one row inside the
|
|
@@ -2530,6 +2594,17 @@ export interface ServerStore {
|
|
|
2530
2594
|
/** The by-id read (the inbox state write's guard — TODO.notify/03: a
|
|
2531
2595
|
* marker lands only on an event that exists and is the caller's). */
|
|
2532
2596
|
getEvent(id: string): Promise<PlatformEvent | null>
|
|
2597
|
+
/** The BULK by-id read (the notify digest's event join): the digest
|
|
2598
|
+
* run awaited getEvent once per queued delivery row — N serial round
|
|
2599
|
+
* trips for N rows. The bulk form resolves every id in ONE statement
|
|
2600
|
+
* per chunk of EVENTS_ID_CHUNK ids (the IN walk against the id UNIQUE
|
|
2601
|
+
* index; never a compound SELECT — the D1 5-term cap rule).
|
|
2602
|
+
*
|
|
2603
|
+
* The answer contract: INPUT-ALIGNED — position i answers the row for
|
|
2604
|
+
* ids[i], null where no event carries the id (exactly the per-id
|
|
2605
|
+
* getEvent loop's answers; a duplicate id answers its row at every
|
|
2606
|
+
* position). An empty list answers [] without issuing a statement. */
|
|
2607
|
+
getEvents(ids: readonly string[]): Promise<(PlatformEvent | null)[]>
|
|
2533
2608
|
/** The subscription grammar's SQL resolution: the columns the pattern
|
|
2534
2609
|
* pins, equality-matched (`WHERE domain = ? AND entity_id = ?` — the
|
|
2535
2610
|
* column split's whole point, never a string scan on a composed key). */
|
|
@@ -2593,17 +2668,20 @@ export interface ServerStore {
|
|
|
2593
2668
|
putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences>
|
|
2594
2669
|
|
|
2595
2670
|
// ── the inbox state (TODO.notify/03) ──
|
|
2596
|
-
/** The user's inbox markers (the feed's join: read/done per
|
|
2597
|
-
* Written lazily at the first act — most events carry no row. */
|
|
2671
|
+
/** The user's inbox markers (the feed's join: read/done/saved per
|
|
2672
|
+
* event). Written lazily at the first act — most events carry no row. */
|
|
2598
2673
|
listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]>
|
|
2599
2674
|
/** The marker write (the upsert on PRIMARY KEY (user_id, event_id)):
|
|
2600
2675
|
* each PRESENT flag sets its stamp (datetime('now')) or clears it
|
|
2601
|
-
* (NULL); absent flags keep.
|
|
2676
|
+
* (NULL); absent flags keep. `saved` (migration 0027, TODO.notify/05's
|
|
2677
|
+
* remainders) is the per-event saved flag, the same stamp/clear
|
|
2678
|
+
* posture. Answers the stored row. */
|
|
2602
2679
|
putNotifyInboxState(input: {
|
|
2603
2680
|
userId: string
|
|
2604
2681
|
eventId: string
|
|
2605
2682
|
read?: boolean
|
|
2606
2683
|
done?: boolean
|
|
2684
|
+
saved?: boolean
|
|
2607
2685
|
}): Promise<NotifyInboxState>
|
|
2608
2686
|
|
|
2609
2687
|
// ── the email channel's delivery store (TODO.notify/04) ──
|