@oimlsmart/platform-server 0.2.11 → 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 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.11",
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
@@ -438,8 +438,9 @@ const INSTRUMENT_REGISTRATION_INSERT_SQL = `INSERT OR IGNORE INTO instrument_reg
438
438
 
439
439
  /** The event append's INSERT … RETURNING * — ONE textual source for
440
440
  * appendEvent and appendEvents alike (the stored row — seq + the
441
- * default at — answers off the write itself). */
442
- const EVENT_INSERT_SQL = 'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *'
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 *'
443
444
 
444
445
  export class D1ServerStore implements ServerStore {
445
446
  /** The RAW binding — the ensure memos (and d1StoreFor's map) key on
@@ -3600,6 +3601,7 @@ export class D1ServerStore implements ServerStore {
3600
3601
  entityId: row.entity_id as string,
3601
3602
  action: row.action as string,
3602
3603
  payload: row.payload as string,
3604
+ mentions: (row.mentions as string | null) ?? null,
3603
3605
  at: row.at as string,
3604
3606
  }
3605
3607
  }
@@ -3611,7 +3613,7 @@ export class D1ServerStore implements ServerStore {
3611
3613
  // two round trips per event, halved).
3612
3614
  const row = await this.stmt(
3613
3615
  EVENT_INSERT_SQL,
3614
- input.id, input.domain, input.entityId, input.action, input.payload,
3616
+ input.id, input.domain, input.entityId, input.action, input.payload, input.mentions ?? null,
3615
3617
  ).first<Record<string, unknown>>()
3616
3618
  return D1ServerStore.toPlatformEvent(row!)
3617
3619
  }
@@ -3630,7 +3632,7 @@ export class D1ServerStore implements ServerStore {
3630
3632
  const chunk = events.slice(i, i + APPEND_EVENTS_CHUNK)
3631
3633
  const statements = chunk.map(e => this.stmt(
3632
3634
  EVENT_INSERT_SQL,
3633
- e.id, e.domain, e.entityId, e.action, e.payload,
3635
+ e.id, e.domain, e.entityId, e.action, e.payload, e.mentions ?? null,
3634
3636
  ))
3635
3637
  const results = await this.db.batch(statements)
3636
3638
  for (const res of results) {
@@ -3842,6 +3844,7 @@ export class D1ServerStore implements ServerStore {
3842
3844
  eventId: row.event_id as string,
3843
3845
  readAt: (row.read_at as string | null) ?? null,
3844
3846
  doneAt: (row.done_at as string | null) ?? null,
3847
+ savedAt: (row.saved_at as string | null) ?? null,
3845
3848
  createdAt: row.created_at as string,
3846
3849
  }
3847
3850
  }
@@ -3860,6 +3863,7 @@ export class D1ServerStore implements ServerStore {
3860
3863
  eventId: string
3861
3864
  read?: boolean
3862
3865
  done?: boolean
3866
+ saved?: boolean
3863
3867
  }): Promise<NotifyInboxState> {
3864
3868
  await this.stmt(
3865
3869
  'INSERT OR IGNORE INTO notify_inbox_state (user_id, event_id) VALUES (?, ?)',
@@ -3877,6 +3881,12 @@ export class D1ServerStore implements ServerStore {
3877
3881
  input.done ? 1 : 0, input.userId, input.eventId,
3878
3882
  ).run()
3879
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
+ }
3880
3890
  const row = await this.stmt(
3881
3891
  'SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?',
3882
3892
  input.userId, input.eventId,
@@ -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
- `INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?) RETURNING *`,
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
  }
@@ -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). The
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).
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
- * `seq` is the feed cursor (the entity_changes journal's pattern). */
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,6 +219,9 @@ 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
 
@@ -226,6 +234,9 @@ export interface EventWriteInput {
226
234
  entityId: string
227
235
  action: string
228
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
229
240
  }
230
241
 
231
242
  /** The key pattern's SQL legs: a subscription pattern (`application/**`,
@@ -330,7 +341,10 @@ export interface NotifyPreferences {
330
341
  * lazily at read"): one row per (user, event), created at the first act
331
342
  * on the inbox row. `readAt` stamps the mark-read (NULL = unread);
332
343
  * `doneAt` stamps the archive (NULL = in the inbox — a done row leaves
333
- * the feed, the marker keeps the state). 'saved' joins with wave 05.
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).
334
348
  * The feed is COMPUTED at read (events × the user's rules × the
335
349
  * visibility gate); this table is the state that computation joins. A
336
350
  * marker on a wiped event never joins (the user's state is their own,
@@ -340,6 +354,7 @@ export interface NotifyInboxState {
340
354
  eventId: string
341
355
  readAt: string | null
342
356
  doneAt: string | null
357
+ savedAt: string | null
343
358
  createdAt: string
344
359
  }
345
360
 
@@ -2593,17 +2608,20 @@ export interface ServerStore {
2593
2608
  putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences>
2594
2609
 
2595
2610
  // ── the inbox state (TODO.notify/03) ──
2596
- /** The user's inbox markers (the feed's join: read/done per event).
2597
- * 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. */
2598
2613
  listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]>
2599
2614
  /** The marker write (the upsert on PRIMARY KEY (user_id, event_id)):
2600
2615
  * each PRESENT flag sets its stamp (datetime('now')) or clears it
2601
- * (NULL); absent flags keep. Answers the stored row. */
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. */
2602
2619
  putNotifyInboxState(input: {
2603
2620
  userId: string
2604
2621
  eventId: string
2605
2622
  read?: boolean
2606
2623
  done?: boolean
2624
+ saved?: boolean
2607
2625
  }): Promise<NotifyInboxState>
2608
2626
 
2609
2627
  // ── the email channel's delivery store (TODO.notify/04) ──