@oimlsmart/platform-server 0.2.10 → 0.2.12
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/package.json +1 -1
- package/src/store/d1.ts +88 -24
- package/src/store/sqlite/events.ts +37 -11
- package/src/store/sqlite/notify.ts +7 -0
- package/src/store/sqlite/schema.sql +12 -2
- package/src/store/sqlite/store.ts +51 -12
- package/src/store/sqlite.ts +11 -20
- package/src/store.ts +118 -24
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/store/d1.ts
CHANGED
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
|
|
32
32
|
import {
|
|
33
|
+
APPEND_EVENTS_CHUNK,
|
|
33
34
|
DEMO_PASSWORD,
|
|
34
35
|
EVENTS_BULK_KEY_CHUNK,
|
|
36
|
+
INSTRUMENT_REGISTRATIONS_CHUNK,
|
|
35
37
|
PUT_ENTITIES_CHUNK,
|
|
36
38
|
StoreUnavailable,
|
|
37
39
|
type AccountEmail,
|
|
@@ -48,6 +50,7 @@ import {
|
|
|
48
50
|
type EntityWriteInput,
|
|
49
51
|
type EventEntityKey,
|
|
50
52
|
type EventKeyFilter,
|
|
53
|
+
type EventWriteInput,
|
|
51
54
|
type FederationPeer,
|
|
52
55
|
type IdentityApproval,
|
|
53
56
|
type IdentityLink,
|
|
@@ -84,6 +87,7 @@ import {
|
|
|
84
87
|
type InstrumentRegistration,
|
|
85
88
|
type InstrumentRegistrationLifecycle,
|
|
86
89
|
type InstrumentRegistrationScopeStatus,
|
|
90
|
+
type InstrumentRegistrationWriteInput,
|
|
87
91
|
type PersonalAccessToken,
|
|
88
92
|
type PlatformEvent,
|
|
89
93
|
resolveOrgContext,
|
|
@@ -423,6 +427,21 @@ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, update
|
|
|
423
427
|
ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
|
|
424
428
|
const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
|
|
425
429
|
|
|
430
|
+
/** The register write's INSERT — ONE textual source for
|
|
431
|
+
* createInstrumentRegistration and createInstrumentRegistrations alike
|
|
432
|
+
* (the batch verb appends RETURNING * so the stored row answers off
|
|
433
|
+
* the write itself; the single verb keeps its .run() + by-id
|
|
434
|
+
* read-back pair). */
|
|
435
|
+
const INSTRUMENT_REGISTRATION_INSERT_SQL = `INSERT OR IGNORE INTO instrument_registrations
|
|
436
|
+
(id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
|
|
437
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
438
|
+
|
|
439
|
+
/** The event append's INSERT … RETURNING * — ONE textual source for
|
|
440
|
+
* appendEvent and appendEvents alike (the stored row — seq + the
|
|
441
|
+
* default at — answers off the write itself). The mentions column
|
|
442
|
+
* (migration 0027) lands from the input, NULL when absent. */
|
|
443
|
+
const EVENT_INSERT_SQL = 'INSERT INTO events (id, domain, entity_id, action, payload, mentions) VALUES (?, ?, ?, ?, ?, ?) RETURNING *'
|
|
444
|
+
|
|
426
445
|
export class D1ServerStore implements ServerStore {
|
|
427
446
|
/** The RAW binding — the ensure memos (and d1StoreFor's map) key on
|
|
428
447
|
* it: the facade below is per-instance and would never hit. */
|
|
@@ -3423,23 +3442,10 @@ export class D1ServerStore implements ServerStore {
|
|
|
3423
3442
|
|
|
3424
3443
|
/** Register the instrument; NULL on the (certificate, serial)
|
|
3425
3444
|
* conflict (the route's honest 409). */
|
|
3426
|
-
async createInstrumentRegistration(input: {
|
|
3427
|
-
id: string
|
|
3428
|
-
certificateId: string
|
|
3429
|
-
holderOrgId: string
|
|
3430
|
-
standardId: string
|
|
3431
|
-
serialNumber: string
|
|
3432
|
-
manufactureDate?: string | null
|
|
3433
|
-
designations?: Record<string, unknown>
|
|
3434
|
-
scopeStatus: InstrumentRegistrationScopeStatus
|
|
3435
|
-
scopeDetail?: string | null
|
|
3436
|
-
registeredBy?: string | null
|
|
3437
|
-
}): Promise<InstrumentRegistration | null> {
|
|
3445
|
+
async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
|
|
3438
3446
|
await this.ensureInstrumentRegistrationSupport()
|
|
3439
3447
|
const res = await this.stmt(
|
|
3440
|
-
|
|
3441
|
-
(id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
|
|
3442
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3448
|
+
INSTRUMENT_REGISTRATION_INSERT_SQL,
|
|
3443
3449
|
input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
|
|
3444
3450
|
input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
|
|
3445
3451
|
input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
|
|
@@ -3448,6 +3454,37 @@ export class D1ServerStore implements ServerStore {
|
|
|
3448
3454
|
return this.getInstrumentRegistration(input.id)
|
|
3449
3455
|
}
|
|
3450
3456
|
|
|
3457
|
+
/** The batch register write (the seam's contract, mechanically):
|
|
3458
|
+
* each row's INSERT OR IGNORE … RETURNING * rides ONE db.batch per
|
|
3459
|
+
* INSTRUMENT_REGISTRATIONS_CHUNK rows, the statements in INPUT order,
|
|
3460
|
+
* the chunks SERIALLY — the register's insertion order IS the CSV's
|
|
3461
|
+
* row order. The per-row answer comes off the batch's own results:
|
|
3462
|
+
* the stored row, or NULL when the RETURNING came back empty (the
|
|
3463
|
+
* (certificate, serial) conflict — the single verb's honest null).
|
|
3464
|
+
* The chunk is the atomic unit: a D1 batch lands all-or-nothing; a
|
|
3465
|
+
* failed chunk throws with its rows unlanded, earlier chunks
|
|
3466
|
+
* standing, later chunks never issued. */
|
|
3467
|
+
async createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]> {
|
|
3468
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
3469
|
+
if (rows.length === 0) return []
|
|
3470
|
+
const out: (InstrumentRegistration | null)[] = []
|
|
3471
|
+
for (let i = 0; i < rows.length; i += INSTRUMENT_REGISTRATIONS_CHUNK) {
|
|
3472
|
+
const chunk = rows.slice(i, i + INSTRUMENT_REGISTRATIONS_CHUNK)
|
|
3473
|
+
const statements = chunk.map(row => this.stmt(
|
|
3474
|
+
`${INSTRUMENT_REGISTRATION_INSERT_SQL} RETURNING *`,
|
|
3475
|
+
row.id, row.certificateId, row.holderOrgId, row.standardId, row.serialNumber,
|
|
3476
|
+
row.manufactureDate ?? null, JSON.stringify(row.designations ?? {}),
|
|
3477
|
+
row.scopeStatus, row.scopeDetail ?? null, row.registeredBy ?? null,
|
|
3478
|
+
))
|
|
3479
|
+
const results = await this.db.batch(statements)
|
|
3480
|
+
for (const res of results) {
|
|
3481
|
+
const stored = res.results[0] as Record<string, unknown> | undefined
|
|
3482
|
+
out.push(stored ? D1ServerStore.toInstrumentRegistration(stored) : null)
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
return out
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3451
3488
|
/** The lifecycle act (the transition rule is the route's); stamps
|
|
3452
3489
|
* updated_at/by. NULL when the register does not carry the id. */
|
|
3453
3490
|
async setInstrumentRegistrationLifecycle(
|
|
@@ -3564,28 +3601,47 @@ export class D1ServerStore implements ServerStore {
|
|
|
3564
3601
|
entityId: row.entity_id as string,
|
|
3565
3602
|
action: row.action as string,
|
|
3566
3603
|
payload: row.payload as string,
|
|
3604
|
+
mentions: (row.mentions as string | null) ?? null,
|
|
3567
3605
|
at: row.at as string,
|
|
3568
3606
|
}
|
|
3569
3607
|
}
|
|
3570
3608
|
|
|
3571
|
-
async appendEvent(input: {
|
|
3572
|
-
id: string
|
|
3573
|
-
domain: string
|
|
3574
|
-
entityId: string
|
|
3575
|
-
action: string
|
|
3576
|
-
payload: string
|
|
3577
|
-
}): Promise<PlatformEvent> {
|
|
3609
|
+
async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
|
|
3578
3610
|
// ONE round trip: the RETURNING clause (SQLite ≥ 3.35, D1 included)
|
|
3579
3611
|
// answers the stored row — seq + the default at — off the INSERT
|
|
3580
3612
|
// itself; the SELECT-by-id read-back retired (the 2026-09-06 audit:
|
|
3581
3613
|
// two round trips per event, halved).
|
|
3582
3614
|
const row = await this.stmt(
|
|
3583
|
-
|
|
3584
|
-
input.id, input.domain, input.entityId, input.action, input.payload,
|
|
3615
|
+
EVENT_INSERT_SQL,
|
|
3616
|
+
input.id, input.domain, input.entityId, input.action, input.payload, input.mentions ?? null,
|
|
3585
3617
|
).first<Record<string, unknown>>()
|
|
3586
3618
|
return D1ServerStore.toPlatformEvent(row!)
|
|
3587
3619
|
}
|
|
3588
3620
|
|
|
3621
|
+
/** The bulk append (the seam's contract, mechanically): each event's
|
|
3622
|
+
* INSERT … RETURNING * rides ONE db.batch per APPEND_EVENTS_CHUNK
|
|
3623
|
+
* rows, the statements in INPUT order, the chunks SERIALLY — the
|
|
3624
|
+
* events' seq order IS the input order. The answer is the stored
|
|
3625
|
+
* rows, input-aligned. The chunk is the atomic unit: a D1 batch
|
|
3626
|
+
* lands all-or-nothing; a failed chunk throws with its events
|
|
3627
|
+
* unlanded, earlier chunks standing, later chunks never issued. */
|
|
3628
|
+
async appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]> {
|
|
3629
|
+
if (events.length === 0) return []
|
|
3630
|
+
const out: PlatformEvent[] = []
|
|
3631
|
+
for (let i = 0; i < events.length; i += APPEND_EVENTS_CHUNK) {
|
|
3632
|
+
const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
|
|
3633
|
+
const statements = chunk.map(e => this.stmt(
|
|
3634
|
+
EVENT_INSERT_SQL,
|
|
3635
|
+
e.id, e.domain, e.entityId, e.action, e.payload, e.mentions ?? null,
|
|
3636
|
+
))
|
|
3637
|
+
const results = await this.db.batch(statements)
|
|
3638
|
+
for (const res of results) {
|
|
3639
|
+
out.push(D1ServerStore.toPlatformEvent(res.results[0] as Record<string, unknown>))
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
return out
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3589
3645
|
async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
|
|
3590
3646
|
const res = await this.stmt(
|
|
3591
3647
|
'SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
|
|
@@ -3788,6 +3844,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3788
3844
|
eventId: row.event_id as string,
|
|
3789
3845
|
readAt: (row.read_at as string | null) ?? null,
|
|
3790
3846
|
doneAt: (row.done_at as string | null) ?? null,
|
|
3847
|
+
savedAt: (row.saved_at as string | null) ?? null,
|
|
3791
3848
|
createdAt: row.created_at as string,
|
|
3792
3849
|
}
|
|
3793
3850
|
}
|
|
@@ -3806,6 +3863,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
3806
3863
|
eventId: string
|
|
3807
3864
|
read?: boolean
|
|
3808
3865
|
done?: boolean
|
|
3866
|
+
saved?: boolean
|
|
3809
3867
|
}): Promise<NotifyInboxState> {
|
|
3810
3868
|
await this.stmt(
|
|
3811
3869
|
'INSERT OR IGNORE INTO notify_inbox_state (user_id, event_id) VALUES (?, ?)',
|
|
@@ -3823,6 +3881,12 @@ export class D1ServerStore implements ServerStore {
|
|
|
3823
3881
|
input.done ? 1 : 0, input.userId, input.eventId,
|
|
3824
3882
|
).run()
|
|
3825
3883
|
}
|
|
3884
|
+
if (input.saved !== undefined) {
|
|
3885
|
+
await this.stmt(
|
|
3886
|
+
"UPDATE notify_inbox_state SET saved_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
|
|
3887
|
+
input.saved ? 1 : 0, input.userId, input.eventId,
|
|
3888
|
+
).run()
|
|
3889
|
+
}
|
|
3826
3890
|
const row = await this.stmt(
|
|
3827
3891
|
'SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?',
|
|
3828
3892
|
input.userId, input.eventId,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// ═══════════════════════════════════════════════════════════════════
|
|
10
10
|
|
|
11
11
|
import { getDb } from './store'
|
|
12
|
-
import { EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type PlatformEvent } from '../../store'
|
|
12
|
+
import { APPEND_EVENTS_CHUNK, EVENTS_BULK_KEY_CHUNK, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type PlatformEvent } from '../../store'
|
|
13
13
|
|
|
14
14
|
interface EventRow {
|
|
15
15
|
seq: number
|
|
@@ -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,26 +30,51 @@ 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
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
|
|
44
|
+
export function appendEvent(input: EventWriteInput): PlatformEvent {
|
|
43
45
|
// ONE statement: RETURNING answers the stored row (seq + the default
|
|
44
46
|
// at) off the INSERT itself — the same halving as the D1 half.
|
|
45
47
|
return toPlatformEvent(
|
|
46
|
-
getDb().prepare(
|
|
47
|
-
|
|
48
|
-
).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,
|
|
49
50
|
)
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** The bulk append (the seam's appendEvents, the 2026-09-07 audit's
|
|
54
|
+
* chain half): each event lands exactly as appendEvent would land it —
|
|
55
|
+
* the INSERT … RETURNING * answers the stored row off the write itself —
|
|
56
|
+
* the answer rows in INPUT order (their seqs strictly increase in it),
|
|
57
|
+
* one transaction per APPEND_EVENTS_CHUNK rows. The chunk is the atomic
|
|
58
|
+
* unit, matching the D1 batch's all-or-nothing; a failed chunk throws
|
|
59
|
+
* with its events unlanded, earlier chunks standing, later chunks never
|
|
60
|
+
* issued. The chunks run serially, so the events' seq order IS the
|
|
61
|
+
* input order. */
|
|
62
|
+
export function appendEvents(events: readonly EventWriteInput[]): PlatformEvent[] {
|
|
63
|
+
if (events.length === 0) return []
|
|
64
|
+
const db = getDb()
|
|
65
|
+
const insert = db.prepare(EVENT_INSERT_SQL)
|
|
66
|
+
const out: PlatformEvent[] = []
|
|
67
|
+
for (let i = 0; i < events.length; i += APPEND_EVENTS_CHUNK) {
|
|
68
|
+
const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
|
|
69
|
+
db.transaction(() => {
|
|
70
|
+
for (const e of chunk) {
|
|
71
|
+
out.push(toPlatformEvent(insert.get(e.id, e.domain, e.entityId, e.action, e.payload, e.mentions ?? null) as EventRow))
|
|
72
|
+
}
|
|
73
|
+
})()
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
/** The feed's raw leg: events past the cursor, seq-ordered. */
|
|
53
79
|
export function eventsAfter(seq: number, limit = 500): PlatformEvent[] {
|
|
54
80
|
const rows = getDb()
|
|
@@ -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,
|
|
@@ -149,7 +149,12 @@ CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store
|
|
|
149
149
|
-- envelope (the summary line, the deep link, the actors, the entity's
|
|
150
150
|
-- store for the read-time visibility gate). seq is the feed cursor;
|
|
151
151
|
-- written inside the acting request's envelope, never blocking the
|
|
152
|
-
-- triggering flow (the mailer doctrine).
|
|
152
|
+
-- triggering flow (the mailer doctrine). mentions (migration 0027,
|
|
153
|
+
-- TODO.notify/05's remainders) is the act's @user mentions as a JSON
|
|
154
|
+
-- array of user ids, resolved by the comment surfaces' parse at event
|
|
155
|
+
-- time and recorded ON THE ROW — a mention is a fact of the act, never
|
|
156
|
+
-- re-derivable from the entity at the feed's read-time computation.
|
|
157
|
+
-- NULL = none.
|
|
153
158
|
CREATE TABLE IF NOT EXISTS events (
|
|
154
159
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
160
|
id TEXT UNIQUE NOT NULL,
|
|
@@ -157,6 +162,7 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
157
162
|
entity_id TEXT NOT NULL,
|
|
158
163
|
action TEXT NOT NULL,
|
|
159
164
|
payload TEXT NOT NULL,
|
|
165
|
+
mentions TEXT,
|
|
160
166
|
at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
161
167
|
);
|
|
162
168
|
CREATE INDEX IF NOT EXISTS idx_events_domain_entity ON events (domain, entity_id);
|
|
@@ -219,7 +225,10 @@ CREATE TABLE IF NOT EXISTS notify_preferences (
|
|
|
219
225
|
-- TODO.notify/03 — the inbox state: the per-user per-event read markers
|
|
220
226
|
-- (migration 0014). One row per (user, event), written lazily at the
|
|
221
227
|
-- 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)
|
|
228
|
+
-- archive (a done row leaves the feed; the marker keeps the state),
|
|
229
|
+
-- saved_at the per-event saved flag (migration 0027, TODO.notify/05's
|
|
230
|
+
-- remainders — the GitHub Save, independent of read/done: a saved row
|
|
231
|
+
-- that is done stays saved in the archive). The
|
|
223
232
|
-- feed itself is COMPUTED at read (events × the user's rules × the
|
|
224
233
|
-- visibility gate) — this table is the state the computation joins, and
|
|
225
234
|
-- a marker on a wiped event simply never joins (the user's state is
|
|
@@ -233,6 +242,7 @@ CREATE TABLE IF NOT EXISTS notify_inbox_state (
|
|
|
233
242
|
event_id TEXT NOT NULL,
|
|
234
243
|
read_at TEXT,
|
|
235
244
|
done_at TEXT,
|
|
245
|
+
saved_at TEXT,
|
|
236
246
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
237
247
|
PRIMARY KEY (user_id, event_id)
|
|
238
248
|
);
|
|
@@ -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).
|
|
@@ -1355,7 +1367,9 @@ import type {
|
|
|
1355
1367
|
InstrumentRegistration,
|
|
1356
1368
|
InstrumentRegistrationLifecycle,
|
|
1357
1369
|
InstrumentRegistrationScopeStatus,
|
|
1370
|
+
InstrumentRegistrationWriteInput,
|
|
1358
1371
|
} from '../../store'
|
|
1372
|
+
import { INSTRUMENT_REGISTRATIONS_CHUNK } from '../../store'
|
|
1359
1373
|
|
|
1360
1374
|
interface InstrumentRegistrationRow {
|
|
1361
1375
|
id: string
|
|
@@ -1430,18 +1444,7 @@ export function getInstrumentRegistration(id: string): InstrumentRegistration |
|
|
|
1430
1444
|
/** Register the instrument; NULL on the (certificate, serial) conflict
|
|
1431
1445
|
* (the same physical unit never registers twice under one certificate —
|
|
1432
1446
|
* the route's honest 409). */
|
|
1433
|
-
export function createInstrumentRegistration(input: {
|
|
1434
|
-
id: string
|
|
1435
|
-
certificateId: string
|
|
1436
|
-
holderOrgId: string
|
|
1437
|
-
standardId: string
|
|
1438
|
-
serialNumber: string
|
|
1439
|
-
manufactureDate?: string | null
|
|
1440
|
-
designations?: Record<string, unknown>
|
|
1441
|
-
scopeStatus: InstrumentRegistrationScopeStatus
|
|
1442
|
-
scopeDetail?: string | null
|
|
1443
|
-
registeredBy?: string | null
|
|
1444
|
-
}): InstrumentRegistration | null {
|
|
1447
|
+
export function createInstrumentRegistration(input: InstrumentRegistrationWriteInput): InstrumentRegistration | null {
|
|
1445
1448
|
const res = getDb().prepare(
|
|
1446
1449
|
`INSERT OR IGNORE INTO instrument_registrations
|
|
1447
1450
|
(id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
|
|
@@ -1455,6 +1458,42 @@ export function createInstrumentRegistration(input: {
|
|
|
1455
1458
|
return getInstrumentRegistration(input.id)
|
|
1456
1459
|
}
|
|
1457
1460
|
|
|
1461
|
+
/** The batch register write (the seam's createInstrumentRegistrations,
|
|
1462
|
+
* the 2026-09-07 audit's REAL J1): each row lands exactly as
|
|
1463
|
+
* createInstrumentRegistration would land it — the INSERT OR IGNORE …
|
|
1464
|
+
* RETURNING * answers the stored row off the write itself (NULL when it
|
|
1465
|
+
* comes back empty: the (certificate, serial) conflict), the per-row
|
|
1466
|
+
* answers aligned with the INPUT order — one transaction per
|
|
1467
|
+
* INSTRUMENT_REGISTRATIONS_CHUNK rows. The chunk is the atomic unit,
|
|
1468
|
+
* matching the D1 batch's all-or-nothing; a failed chunk throws with
|
|
1469
|
+
* its rows unlanded, earlier chunks standing, later chunks never
|
|
1470
|
+
* issued. The chunks run serially, so the register's insertion order
|
|
1471
|
+
* IS the input's row order. */
|
|
1472
|
+
export function createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): (InstrumentRegistration | null)[] {
|
|
1473
|
+
if (rows.length === 0) return []
|
|
1474
|
+
const db = getDb()
|
|
1475
|
+
const insert = db.prepare(
|
|
1476
|
+
`INSERT OR IGNORE INTO instrument_registrations
|
|
1477
|
+
(id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
|
|
1478
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
1479
|
+
)
|
|
1480
|
+
const out: (InstrumentRegistration | null)[] = []
|
|
1481
|
+
for (let i = 0; i < rows.length; i += INSTRUMENT_REGISTRATIONS_CHUNK) {
|
|
1482
|
+
const chunk = rows.slice(i, i + INSTRUMENT_REGISTRATIONS_CHUNK)
|
|
1483
|
+
db.transaction(() => {
|
|
1484
|
+
for (const row of chunk) {
|
|
1485
|
+
const stored = insert.get(
|
|
1486
|
+
row.id, row.certificateId, row.holderOrgId, row.standardId, row.serialNumber,
|
|
1487
|
+
row.manufactureDate ?? null, JSON.stringify(row.designations ?? {}),
|
|
1488
|
+
row.scopeStatus, row.scopeDetail ?? null, row.registeredBy ?? null,
|
|
1489
|
+
) as InstrumentRegistrationRow | undefined
|
|
1490
|
+
out.push(stored ? instrumentRegistrationPayload(stored) : null)
|
|
1491
|
+
}
|
|
1492
|
+
})()
|
|
1493
|
+
}
|
|
1494
|
+
return out
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1458
1497
|
/** The lifecycle act (the transition rule is the route's); stamps
|
|
1459
1498
|
* updated_at/by. NULL when the register does not carry the id. */
|
|
1460
1499
|
export function setInstrumentRegistrationLifecycle(
|
package/src/store/sqlite.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createOrgMembership,
|
|
23
23
|
createOrgRegistryOrg,
|
|
24
24
|
createInstrumentRegistration,
|
|
25
|
+
createInstrumentRegistrations,
|
|
25
26
|
createSession,
|
|
26
27
|
decideCertificateHolderClaim,
|
|
27
28
|
decideIdentityApproval,
|
|
@@ -91,6 +92,7 @@ import {
|
|
|
91
92
|
} from './sqlite/entities'
|
|
92
93
|
import {
|
|
93
94
|
appendEvent,
|
|
95
|
+
appendEvents,
|
|
94
96
|
eventsAfter,
|
|
95
97
|
eventsMatching,
|
|
96
98
|
getEvent,
|
|
@@ -189,7 +191,7 @@ import {
|
|
|
189
191
|
updateOpAccount,
|
|
190
192
|
updateUserName,
|
|
191
193
|
} from './sqlite/op-accounts-store'
|
|
192
|
-
import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EntityWriteInput, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
194
|
+
import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EntityWriteInput, type EventEntityKey, type EventKeyFilter, type EventWriteInput, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type InstrumentRegistrationWriteInput, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
193
195
|
import {
|
|
194
196
|
advanceWebauthnCounter,
|
|
195
197
|
consumeMfaPending,
|
|
@@ -571,20 +573,12 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
571
573
|
async getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null> {
|
|
572
574
|
return getInstrumentRegistration(id)
|
|
573
575
|
},
|
|
574
|
-
async createInstrumentRegistration(input: {
|
|
575
|
-
id: string
|
|
576
|
-
certificateId: string
|
|
577
|
-
holderOrgId: string
|
|
578
|
-
standardId: string
|
|
579
|
-
serialNumber: string
|
|
580
|
-
manufactureDate?: string | null
|
|
581
|
-
designations?: Record<string, unknown>
|
|
582
|
-
scopeStatus: InstrumentRegistrationScopeStatus
|
|
583
|
-
scopeDetail?: string | null
|
|
584
|
-
registeredBy?: string | null
|
|
585
|
-
}): Promise<InstrumentRegistration | null> {
|
|
576
|
+
async createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null> {
|
|
586
577
|
return createInstrumentRegistration(input)
|
|
587
578
|
},
|
|
579
|
+
async createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]> {
|
|
580
|
+
return createInstrumentRegistrations(rows)
|
|
581
|
+
},
|
|
588
582
|
async setInstrumentRegistrationLifecycle(
|
|
589
583
|
id: string,
|
|
590
584
|
lifecycle: InstrumentRegistrationLifecycle,
|
|
@@ -1044,15 +1038,12 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
1044
1038
|
},
|
|
1045
1039
|
|
|
1046
1040
|
// ── the platform event store (TODO.notify/01) ──
|
|
1047
|
-
async appendEvent(input: {
|
|
1048
|
-
id: string
|
|
1049
|
-
domain: string
|
|
1050
|
-
entityId: string
|
|
1051
|
-
action: string
|
|
1052
|
-
payload: string
|
|
1053
|
-
}): Promise<PlatformEvent> {
|
|
1041
|
+
async appendEvent(input: EventWriteInput): Promise<PlatformEvent> {
|
|
1054
1042
|
return appendEvent(input)
|
|
1055
1043
|
},
|
|
1044
|
+
async appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]> {
|
|
1045
|
+
return appendEvents(events)
|
|
1046
|
+
},
|
|
1056
1047
|
async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
|
|
1057
1048
|
return eventsAfter(seq, limit)
|
|
1058
1049
|
},
|
package/src/store.ts
CHANGED
|
@@ -206,7 +206,12 @@ export const PUT_ENTITIES_CHUNK = 50
|
|
|
206
206
|
* composed key is derived at read, never stored. `payload` is the JSON
|
|
207
207
|
* envelope the catalog row declares (the summary line, the deep link,
|
|
208
208
|
* the actors, the entity's store for the read-time visibility gate).
|
|
209
|
-
* `
|
|
209
|
+
* `mentions` (migration 0027, TODO.notify/05's remainders) is the act's
|
|
210
|
+
* @user mentions as a JSON array of user ids — the comment surfaces'
|
|
211
|
+
* parse resolves them at event time and records them ON THE ROW (a
|
|
212
|
+
* mention is a fact of the act; the feed's read-time reason computation
|
|
213
|
+
* could never re-derive it from the entity). NULL = none. `seq` is the
|
|
214
|
+
* feed cursor (the entity_changes journal's pattern). */
|
|
210
215
|
export interface PlatformEvent {
|
|
211
216
|
seq: number
|
|
212
217
|
id: string
|
|
@@ -214,9 +219,26 @@ export interface PlatformEvent {
|
|
|
214
219
|
entityId: string
|
|
215
220
|
action: string
|
|
216
221
|
payload: string
|
|
222
|
+
/** The mentioned user ids' JSON array (raw, the payload/channelOverrides
|
|
223
|
+
* posture — the consumer parses); NULL = no mentions. */
|
|
224
|
+
mentions: string | null
|
|
217
225
|
at: string
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
/** One row of the bulk append (appendEvents): appendEvent's legs — the
|
|
229
|
+
* same fields, so each event lands exactly as the single-row verb
|
|
230
|
+
* would land it (the EntityWriteInput pattern). */
|
|
231
|
+
export interface EventWriteInput {
|
|
232
|
+
id: string
|
|
233
|
+
domain: string
|
|
234
|
+
entityId: string
|
|
235
|
+
action: string
|
|
236
|
+
payload: string
|
|
237
|
+
/** The act's mentioned user ids (JSON array; TODO.notify/05's
|
|
238
|
+
* remainders) — absent/NULL lands NULL (no mentions). */
|
|
239
|
+
mentions?: string | null
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
/** The key pattern's SQL legs: a subscription pattern (`application/**`,
|
|
221
243
|
* `certificate` + `issued` across the domain, one entity's
|
|
222
244
|
* `test-run/asg-…-001/**`) compiles to the columns it pins; absent legs
|
|
@@ -243,6 +265,17 @@ export interface EventEntityKey {
|
|
|
243
265
|
* backends stay answer-identical. */
|
|
244
266
|
export const EVENTS_BULK_KEY_CHUNK = 49
|
|
245
267
|
|
|
268
|
+
/** The bulk append's chunk, in ROWS (appendEvents): each event
|
|
269
|
+
* contributes ONE statement (the INSERT … RETURNING * answers the
|
|
270
|
+
* stored row off the write itself, the appendEvent halving), so a
|
|
271
|
+
* chunk is one db.batch of APPEND_EVENTS_CHUNK statements — the
|
|
272
|
+
* INSTRUMENT_REGISTRATIONS_CHUNK order. The chunk bounds the ATOMIC
|
|
273
|
+
* unit (a D1 batch is all-or-nothing; the SQLite half's per-chunk
|
|
274
|
+
* transaction matches it). Chunks issue SERIALLY, in input order —
|
|
275
|
+
* the events' seq order IS the input order, and parallel chunks would
|
|
276
|
+
* forfeit it. */
|
|
277
|
+
export const APPEND_EVENTS_CHUNK = 50
|
|
278
|
+
|
|
246
279
|
// ── the notification subscriptions store (TODO.notify/02) ────────────
|
|
247
280
|
|
|
248
281
|
/** The rule row's mode: 'subscribe' adds the user to the candidates of
|
|
@@ -308,7 +341,10 @@ export interface NotifyPreferences {
|
|
|
308
341
|
* lazily at read"): one row per (user, event), created at the first act
|
|
309
342
|
* on the inbox row. `readAt` stamps the mark-read (NULL = unread);
|
|
310
343
|
* `doneAt` stamps the archive (NULL = in the inbox — a done row leaves
|
|
311
|
-
* the feed, the marker keeps the state)
|
|
344
|
+
* the feed, the marker keeps the state); `savedAt` (migration 0027,
|
|
345
|
+
* TODO.notify/05's remainders) stamps the per-event saved flag — the
|
|
346
|
+
* GitHub Save, independent of read/done (a saved row that is done stays
|
|
347
|
+
* saved in the archive).
|
|
312
348
|
* The feed is COMPUTED at read (events × the user's rules × the
|
|
313
349
|
* visibility gate); this table is the state that computation joins. A
|
|
314
350
|
* marker on a wiped event never joins (the user's state is their own,
|
|
@@ -318,6 +354,7 @@ export interface NotifyInboxState {
|
|
|
318
354
|
eventId: string
|
|
319
355
|
readAt: string | null
|
|
320
356
|
doneAt: string | null
|
|
357
|
+
savedAt: string | null
|
|
321
358
|
createdAt: string
|
|
322
359
|
}
|
|
323
360
|
|
|
@@ -1339,6 +1376,35 @@ export interface InstrumentRegistration {
|
|
|
1339
1376
|
updatedBy: string | null
|
|
1340
1377
|
}
|
|
1341
1378
|
|
|
1379
|
+
/** One row of the batch register write (createInstrumentRegistrations):
|
|
1380
|
+
* createInstrumentRegistration's legs — the same fields, so each row
|
|
1381
|
+
* lands exactly as the single-row verb would land it (the
|
|
1382
|
+
* EntityWriteInput pattern). */
|
|
1383
|
+
export interface InstrumentRegistrationWriteInput {
|
|
1384
|
+
id: string
|
|
1385
|
+
certificateId: string
|
|
1386
|
+
holderOrgId: string
|
|
1387
|
+
standardId: string
|
|
1388
|
+
serialNumber: string
|
|
1389
|
+
manufactureDate?: string | null
|
|
1390
|
+
designations?: Record<string, unknown>
|
|
1391
|
+
scopeStatus: InstrumentRegistrationScopeStatus
|
|
1392
|
+
scopeDetail?: string | null
|
|
1393
|
+
registeredBy?: string | null
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** The batch register write's chunk, in ROWS (createInstrumentRegistrations):
|
|
1397
|
+
* each row contributes ONE statement (the INSERT OR IGNORE … RETURNING *
|
|
1398
|
+
* answers the stored row off the write itself), so a chunk is one
|
|
1399
|
+
* db.batch of INSTRUMENT_REGISTRATIONS_CHUNK statements — half the
|
|
1400
|
+
* PUT_ENTITIES_CHUNK statement load, the same conservative order. The
|
|
1401
|
+
* chunk bounds the ATOMIC unit (a D1 batch is all-or-nothing; the
|
|
1402
|
+
* SQLite half's per-chunk transaction matches it). Chunks issue
|
|
1403
|
+
* SERIALLY, in input order — the register's insertion order IS the
|
|
1404
|
+
* input's row order (the CSV commit's chain events sequence after it,
|
|
1405
|
+
* same order), and parallel chunks would forfeit it. */
|
|
1406
|
+
export const INSTRUMENT_REGISTRATIONS_CHUNK = 50
|
|
1407
|
+
|
|
1342
1408
|
/** Per-store org fields for the READ visibility (the multi-party
|
|
1343
1409
|
* model: a row is visible when ANY named field equals the user's
|
|
1344
1410
|
* org). The field names are the entities' REAL ones (verified against
|
|
@@ -2357,18 +2423,35 @@ export interface ServerStore {
|
|
|
2357
2423
|
/** Register the instrument. NULL on the (certificate_id,
|
|
2358
2424
|
* serial_number) conflict — the same physical unit never registers
|
|
2359
2425
|
* twice under one certificate (the route's honest 409). */
|
|
2360
|
-
createInstrumentRegistration(input:
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2426
|
+
createInstrumentRegistration(input: InstrumentRegistrationWriteInput): Promise<InstrumentRegistration | null>
|
|
2427
|
+
/** The BATCH register write (the 2026-09-07 performance audit's REAL
|
|
2428
|
+
* J1 — the CSV commit awaited createInstrumentRegistration per
|
|
2429
|
+
* imported row: ~10⁴ rows = minutes of serial D1 writes at demo
|
|
2430
|
+
* latency, two round trips each): every row lands exactly as the
|
|
2431
|
+
* single-row verb would land it, but the rows ride ONE db.batch per
|
|
2432
|
+
* INSTRUMENT_REGISTRATIONS_CHUNK rows, the INSERT OR IGNORE …
|
|
2433
|
+
* RETURNING * answering the stored row off the write itself (the
|
|
2434
|
+
* appendEvent halving — never the INSERT + SELECT-by-id pair).
|
|
2435
|
+
*
|
|
2436
|
+
* The per-row answer, aligned with the INPUT order: the stored row,
|
|
2437
|
+
* or NULL on the (certificate_id, serial_number) conflict — the
|
|
2438
|
+
* single verb's honest null, so the route's per-row refusal leg (the
|
|
2439
|
+
* serial registered between the evaluation and the commit) rides
|
|
2440
|
+
* unchanged.
|
|
2441
|
+
*
|
|
2442
|
+
* The ordering contract: the statements ride each batch in input
|
|
2443
|
+
* order and the chunks issue SERIALLY, so the register's insertion
|
|
2444
|
+
* order IS the input's row order (the CSV row order; the commit's
|
|
2445
|
+
* chain events sequence after it, same order). A caller
|
|
2446
|
+
* parallelizing the call itself forfeits the contract.
|
|
2447
|
+
*
|
|
2448
|
+
* The failure contract mirrors putEntities: the chunk is the ATOMIC
|
|
2449
|
+
* unit (a D1 batch is all-or-nothing; the SQLite half wraps each
|
|
2450
|
+
* chunk in a transaction). A failed chunk lands NOTHING of its rows
|
|
2451
|
+
* and the call throws — earlier chunks' writes STAND, later chunks
|
|
2452
|
+
* never issue. An empty rows list resolves to [] without issuing a
|
|
2453
|
+
* statement. */
|
|
2454
|
+
createInstrumentRegistrations(rows: readonly InstrumentRegistrationWriteInput[]): Promise<(InstrumentRegistration | null)[]>
|
|
2372
2455
|
/** The lifecycle act (registered ⇄ out_of_service → withdrawn; the
|
|
2373
2456
|
* transition RULE is the route's — withdrawn is terminal): stamps
|
|
2374
2457
|
* updated_at/by. NULL when the register does not carry the id. */
|
|
@@ -2439,13 +2522,21 @@ export interface ServerStore {
|
|
|
2439
2522
|
* acting request's envelope). Answers the stored row (seq + at read
|
|
2440
2523
|
* back) off the INSERT's own RETURNING — ONE round trip, never the
|
|
2441
2524
|
* INSERT + SELECT-by-id pair. */
|
|
2442
|
-
appendEvent(input:
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2525
|
+
appendEvent(input: EventWriteInput): Promise<PlatformEvent>
|
|
2526
|
+
/** The BULK append (the 2026-09-07 performance audit's chain half —
|
|
2527
|
+
* the CSV registration commit's one chain event per imported serial,
|
|
2528
|
+
* a second serial loop after the register writes): every event lands
|
|
2529
|
+
* exactly as appendEvent would land it, the rows riding ONE
|
|
2530
|
+
* db.batch per APPEND_EVENTS_CHUNK rows. Answers the stored rows in
|
|
2531
|
+
* INPUT order — the seqs strictly increase in it (the statements
|
|
2532
|
+
* ride each batch in input order, the chunks SERIALLY; the same
|
|
2533
|
+
* semantic contract as putEntities').
|
|
2534
|
+
*
|
|
2535
|
+
* The failure contract mirrors putEntities: the chunk is the ATOMIC
|
|
2536
|
+
* unit; a failed chunk lands NOTHING of its events and the call
|
|
2537
|
+
* throws — earlier chunks stand, later chunks never issue. An empty
|
|
2538
|
+
* list resolves to [] without issuing a statement. */
|
|
2539
|
+
appendEvents(events: readonly EventWriteInput[]): Promise<PlatformEvent[]>
|
|
2449
2540
|
/** The feed's raw leg: events past the cursor, seq-ordered. The
|
|
2450
2541
|
* visibility gate is the READER's layer (server/notify-feed.ts) —
|
|
2451
2542
|
* never waived here, never duplicated into the SQL. */
|
|
@@ -2517,17 +2608,20 @@ export interface ServerStore {
|
|
|
2517
2608
|
putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences>
|
|
2518
2609
|
|
|
2519
2610
|
// ── the inbox state (TODO.notify/03) ──
|
|
2520
|
-
/** The user's inbox markers (the feed's join: read/done per
|
|
2521
|
-
* Written lazily at the first act — most events carry no row. */
|
|
2611
|
+
/** The user's inbox markers (the feed's join: read/done/saved per
|
|
2612
|
+
* event). Written lazily at the first act — most events carry no row. */
|
|
2522
2613
|
listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]>
|
|
2523
2614
|
/** The marker write (the upsert on PRIMARY KEY (user_id, event_id)):
|
|
2524
2615
|
* each PRESENT flag sets its stamp (datetime('now')) or clears it
|
|
2525
|
-
* (NULL); absent flags keep.
|
|
2616
|
+
* (NULL); absent flags keep. `saved` (migration 0027, TODO.notify/05's
|
|
2617
|
+
* remainders) is the per-event saved flag, the same stamp/clear
|
|
2618
|
+
* posture. Answers the stored row. */
|
|
2526
2619
|
putNotifyInboxState(input: {
|
|
2527
2620
|
userId: string
|
|
2528
2621
|
eventId: string
|
|
2529
2622
|
read?: boolean
|
|
2530
2623
|
done?: boolean
|
|
2624
|
+
saved?: boolean
|
|
2531
2625
|
}): Promise<NotifyInboxState>
|
|
2532
2626
|
|
|
2533
2627
|
// ── the email channel's delivery store (TODO.notify/04) ──
|