mikser-io 9.49.0 → 9.50.0

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.
@@ -844,6 +844,35 @@ AsyncLocalStorage than the engine's, queries record no edges, and index pages,
844
844
  sitemaps and feeds silently stop rebuilding. Production consumers resolve both
845
845
  from their own tree, so the problem is local to the dev workspace.
846
846
 
847
+ ## Roles
848
+
849
+ Enforcement needs only the flat capability list. Explaining a refusal needs the
850
+ role — and without it, an admin token and a site with no roles configured are
851
+ indistinguishable from inside a session.
852
+
853
+ | Export | Does |
854
+ | --- | --- |
855
+ | `describeAuthority({ capabilities, roles, catalogue, summaries })` | everything a session can say about its own authority |
856
+ | `reachOf(capabilities)` | `{ writable, readOnly }` as collection names |
857
+ | `actingRole(held, catalogue)` | which role is in force |
858
+ | `otherRoles(held, catalogue, summaries)` | who to ask, and what they add |
859
+ | `explainRefusal({ capability, role, target, catalogue, summaries })` | the sentence an agent repeats |
860
+
861
+ `readOnly` is the field that makes a refusal explainable, and it is more useful
862
+ than the capabilities it comes from because it is already in the vocabulary the
863
+ person asking uses.
864
+
865
+ A principal can hold several roles. `actingRole` returns the one whose
866
+ capabilities cover the others — roles are normally written as widening tiers —
867
+ and `null` when none dominates, because the acting authority genuinely is the
868
+ union and naming half of it would be a lie.
869
+
870
+ > **Informational, permanently.** Naming the role that could do something is
871
+ > what makes a handoff possible. There is no way to request one and none should
872
+ > be added: a role is a decision about a person, taken by whoever configures the
873
+ > site, and an agent's part is to say what it cannot do and stop. `explainRefusal`
874
+ > deliberately suggests no retry, escalation or workaround — a test asserts it.
875
+
847
876
  ## Auth
848
877
 
849
878
  Building a token-gated or loopback-only route.
package/index.js CHANGED
@@ -2,6 +2,7 @@ export { default as runtime } from './src/runtime.js'
2
2
  export * as constants from './src/constants.js'
3
3
  export * from './src/utils.js'
4
4
  export * from './src/auth.js'
5
+ export * from './src/roles.js'
5
6
  export * from './src/report.js'
6
7
  // The diagnostics behind --explain. Exported so a transport — the MCP tool
7
8
  // surface, the api plugin's routes — can serve the same structured report the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.49.0",
3
+ "version": "9.50.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/changeset.js CHANGED
@@ -55,7 +55,13 @@ registerSchema('change_sets', `
55
55
  -- but there is nothing to revert FROM, which is a different answer
56
56
  -- from "no such change set".
57
57
  recorded_at INTEGER,
58
- recorded_as TEXT
58
+ recorded_as TEXT,
59
+ -- Why a consumer could not record this set. A set that failed is not
60
+ -- a set that is waiting: without somewhere to put the reason, a
61
+ -- permanent failure and a pending commit look identical, and both
62
+ -- read as a null commit forever.
63
+ commit_error TEXT,
64
+ commit_attempts INTEGER NOT NULL DEFAULT 0
59
65
  );
60
66
  CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
61
67
  ON mikser_change_sets (created_at DESC);
@@ -185,6 +191,20 @@ function prune(handle) {
185
191
  `).run(KEEP_SETS)
186
192
  }
187
193
 
194
+ // Said once per process, not once per write: a broken log is one condition,
195
+ // and repeating it per write buries the builds that follow it.
196
+ let failureReported = false
197
+ function reportChangeSetFailure(err) {
198
+ if (failureReported) return
199
+ failureReported = true
200
+ const message = 'The change-set log could not be written (%s). Writes still land on disk, but they cannot be '
201
+ + 'listed or undone until this is fixed.'
202
+ try {
203
+ runtime.engine?.logger?.error(message, err.message)
204
+ } catch { /* no logger yet — the console is what is left */ }
205
+ if (!runtime.engine?.logger) console.error(message.replace('%s', err.message))
206
+ }
207
+
188
208
  function rowsToSets(handle, rows) {
189
209
  const stmt = handle.prepare(
190
210
  'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
@@ -200,6 +220,8 @@ function rowsToSets(handle, rows) {
200
220
  closed: row.closed_at != null,
201
221
  recordedAt: row.recorded_at ?? null,
202
222
  recordedAs: row.recorded_as ?? null,
223
+ commitError: row.commit_error ?? null,
224
+ commitAttempts: row.commit_attempts ?? 0,
203
225
  paths: paths.map(p => p.path),
204
226
  deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
205
227
  }
@@ -217,6 +239,8 @@ function memorySets(filter = () => true) {
217
239
  closed: Boolean(set.closedAt),
218
240
  recordedAt: set.recordedAt ?? null,
219
241
  recordedAs: set.recordedAs ?? null,
242
+ commitError: set.commitError ?? null,
243
+ commitAttempts: set.commitAttempts ?? 0,
220
244
  paths: [...set.paths.keys()],
221
245
  deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
222
246
  }))
@@ -283,7 +307,16 @@ export function recordChangeSetWrite({
283
307
 
284
308
  const handle = db()
285
309
  if (handle) {
286
- try { persist(handle, set, rel, operation, entityId) } catch { /* memory still holds it */ }
310
+ try {
311
+ persist(handle, set, rel, operation, entityId)
312
+ } catch (err) {
313
+ // Loud. A swallowed failure here is invisible in exactly the way
314
+ // that matters: the write succeeds, an id comes back, and the log
315
+ // it points at silently never gains a row — which is how a stale
316
+ // column shape turned the whole feature into a no-op that looked
317
+ // like it was working.
318
+ reportChangeSetFailure(err)
319
+ }
287
320
  }
288
321
  return set.id
289
322
  }
@@ -298,7 +331,9 @@ export function pendingChangeSets() {
298
331
  SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
299
332
  `).all()
300
333
  return rowsToSets(handle, rows).filter(set => set.paths.length)
301
- } catch { /* fall through to memory */ }
334
+ } catch (err) {
335
+ reportChangeSetFailure(err)
336
+ }
302
337
  }
303
338
  return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
304
339
  }
@@ -313,7 +348,9 @@ export function listChangeSets({ limit = 20 } = {}) {
313
348
  SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
314
349
  `).all(Math.max(1, Math.min(limit, 200)))
315
350
  return rowsToSets(handle, rows)
316
- } catch { /* fall through to memory */ }
351
+ } catch (err) {
352
+ reportChangeSetFailure(err)
353
+ }
317
354
  }
318
355
  return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
319
356
  }
@@ -338,13 +375,13 @@ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
338
375
  const at = Date.now()
339
376
  for (const id of ids) {
340
377
  const set = memory.get(id)
341
- if (set) { set.recordedAt = at; set.recordedAs = recordedAs }
378
+ if (set) { set.recordedAt = at; set.recordedAs = recordedAs; set.commitError = null }
342
379
  }
343
380
  const handle = db()
344
381
  if (!handle) return
345
382
  try {
346
383
  const stmt = handle.prepare(
347
- 'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ? WHERE id = ?')
384
+ 'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ?, commit_error = NULL WHERE id = ?')
348
385
  for (const id of ids) stmt.run(at, recordedAs, id)
349
386
  } catch { /* memory still holds it */ }
350
387
  }
@@ -355,6 +392,30 @@ export function clearChangeSets(ids = [], recordedAs = null) {
355
392
  markChangeSetsRecorded(ids, recordedAs)
356
393
  }
357
394
 
395
+ // Record that a consumer tried and failed. The set stays pending — a failure
396
+ // is worth retrying, and a transient one usually succeeds next pass — but the
397
+ // reason is now visible instead of the set sitting at `committed: null` with
398
+ // nothing to say why.
399
+ export function markChangeSetFailed(id, error) {
400
+ const message = String(error?.stderr || error?.message || error || 'unknown error').slice(0, 500)
401
+ const set = memory.get(id)
402
+ if (set) {
403
+ set.commitError = message
404
+ set.commitAttempts = (set.commitAttempts ?? 0) + 1
405
+ }
406
+ const handle = db()
407
+ if (!handle) return
408
+ try {
409
+ handle.prepare(`
410
+ UPDATE mikser_change_sets
411
+ SET commit_error = ?, commit_attempts = commit_attempts + 1
412
+ WHERE id = ?
413
+ `).run(message, id)
414
+ } catch (err) {
415
+ reportChangeSetFailure(err)
416
+ }
417
+ }
418
+
358
419
  export function forgetAllChangeSets() {
359
420
  memory.clear()
360
421
  const handle = db()
@@ -104,6 +104,78 @@ let db = null
104
104
  // duplicate detection. Same name twice = the later registration wins
105
105
  // (with a warning). Convention: `<owner>` matching the table prefix
106
106
  // (`catalog`, `manifest`, `vector`, etc.).
107
+ // Column names a CREATE TABLE body declares, in order.
108
+ //
109
+ // Only the leading identifier of each top-level comma-separated clause, and
110
+ // only when it is not a table constraint. Good enough for the schemas this
111
+ // engine registers, and deliberately not a SQL parser.
112
+ function columnsFrom(sqlScript, table) {
113
+ const re = new RegExp(
114
+ `CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\`"'\\[]?${table}[\`"'\\]]?\\s*\\(`, 'i')
115
+ const m = re.exec(sqlScript)
116
+ if (!m) return []
117
+ // Walk to the matching close paren so nested types and CHECK(...) do not
118
+ // end the body early.
119
+ let depth = 1
120
+ let i = m.index + m[0].length
121
+ const start = i
122
+ for (; i < sqlScript.length && depth > 0; i++) {
123
+ if (sqlScript[i] === '(') depth++
124
+ else if (sqlScript[i] === ')') depth--
125
+ }
126
+ const body = sqlScript.slice(start, i - 1)
127
+
128
+ const clauses = []
129
+ let current = ''
130
+ depth = 0
131
+ for (const ch of body) {
132
+ if (ch === '(') depth++
133
+ else if (ch === ')') depth--
134
+ if (ch === ',' && depth === 0) { clauses.push(current); current = '' } else current += ch
135
+ }
136
+ clauses.push(current)
137
+
138
+ const CONSTRAINTS = new Set(['primary', 'unique', 'foreign', 'check', 'constraint'])
139
+ return clauses
140
+ .map(clause => clause.replace(/--[^\n]*/g, '').trim())
141
+ .filter(Boolean)
142
+ .map(clause => clause.split(/\s+/)[0].replace(/["`\[\]]/g, ''))
143
+ .filter(name => name && !CONSTRAINTS.has(name.toLowerCase()))
144
+ }
145
+
146
+ // Add columns a DURABLE table has grown since it was created.
147
+ //
148
+ // `CREATE TABLE IF NOT EXISTS` does nothing to a table that already exists, so
149
+ // re-applying a schema never adds a column. For a cache table that is
150
+ // invisible: the version bump wipes and recreates it. A durable table is
151
+ // exactly the one that SURVIVES the wipe, so it is the only kind that can go
152
+ // stale — and it goes stale silently, with every insert naming the new column
153
+ // failing against a table that still has the old shape.
154
+ function migrateDurableColumns(handle, schemas, logger) {
155
+ for (const value of schemas?.values?.() ?? []) {
156
+ const { sql, durable } = schemaEntry(value)
157
+ if (!durable) continue
158
+ for (const table of tableNamesFrom(sql)) {
159
+ let existing
160
+ try {
161
+ existing = new Set(handle.prepare(`PRAGMA table_info("${table}")`).all().map(c => c.name))
162
+ } catch { continue }
163
+ if (!existing.size) continue
164
+ for (const column of columnsFrom(sql, table)) {
165
+ if (existing.has(column)) continue
166
+ // Only ever ADD. Dropping or retyping a column in a durable
167
+ // table would discard data the whole flag exists to keep.
168
+ try {
169
+ handle.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}"`)
170
+ logger?.info('Durable table %s gained column %s', table, column)
171
+ } catch (err) {
172
+ logger?.warn('Could not add column %s to %s: %s', column, table, err.message)
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
107
179
  // Table names a schema script creates. Used to decide what a cache wipe
108
180
  // must leave alone — the registry knows schema NAMES, and the wipe works in
109
181
  // tables.
@@ -498,7 +570,8 @@ export function createSqliteDatabase({
498
570
  }
499
571
 
500
572
  // Apply each subsystem's registered schema script. Idempotent
501
- // CREATE statements mean replay-safe across opens.
573
+ // CREATE statements mean replay-safe across opens — but only for
574
+ // tables they can recreate; see migrateDurableColumns below.
502
575
  for (const [name, value] of schemas) {
503
576
  const { sql: sqlScript } = schemaEntry(value)
504
577
  try {
@@ -511,6 +584,11 @@ export function createSqliteDatabase({
511
584
  throw new Error(`Schema "${name}" failed to apply: ${err.message}`)
512
585
  }
513
586
  }
587
+
588
+ // A durable table survives the wipe, so re-applying its CREATE is not
589
+ // enough to give it a column the schema has grown. Reconciled here,
590
+ // additively.
591
+ migrateDurableColumns(handle, schemas, logger)
514
592
  }
515
593
 
516
594
  function close() {
package/src/roles.js ADDED
@@ -0,0 +1,145 @@
1
+ // What a principal may do, in words rather than in capability strings.
2
+ //
3
+ // Enforcement only ever needs the flat list: does this credential carry
4
+ // `drive:layouts:write`, yes or no. That is enough to refuse a request and not
5
+ // nearly enough to EXPLAIN one. A session holding eighteen capabilities cannot
6
+ // tell whether those eighteen are a role called admin, whether narrower roles
7
+ // exist, or which one it is acting as — so an admin token and a site with no
8
+ // roles configured look exactly the same from inside.
9
+ //
10
+ // The difference shows up in what an agent says when it is stopped:
11
+ //
12
+ // "I got a 403 writing to styles/tokens/buttons.css."
13
+ // "I'm connected as editor, which does not include drive:styles:write.
14
+ // That file is the design system — this needs whoever built the site."
15
+ //
16
+ // The first invites working around the refusal. The second is a sentence the
17
+ // end user can forward to the person who can actually do it.
18
+ //
19
+ // INFORMATIONAL, deliberately and permanently. Naming the role that could do
20
+ // something is how a handoff is made possible; there is no way to ask for one
21
+ // and none should be added. A role is a decision about a person, taken by
22
+ // whoever configures the site, and an agent's part in it is to say what it
23
+ // cannot do and stop.
24
+
25
+ // Capabilities follow `drive:<name>` to read and `drive:<name>:write` to
26
+ // write. That convention is the whole mapping — it needs no list of endpoints
27
+ // to stay correct as collections are added.
28
+ const DRIVE = /^drive:([^:]+)(?::write)?$/
29
+
30
+ // Split a capability list into what it can change and what it can only look
31
+ // at. `readOnly` is the field that makes a refusal explainable, and it is more
32
+ // useful than the capabilities it is derived from because it is already in the
33
+ // vocabulary the person asking uses: collection names, not verbs.
34
+ export function reachOf(capabilities = []) {
35
+ const readable = new Set()
36
+ const writable = new Set()
37
+ for (const capability of capabilities ?? []) {
38
+ const m = DRIVE.exec(capability)
39
+ if (!m) continue
40
+ readable.add(m[1])
41
+ if (capability.endsWith(':write')) writable.add(m[1])
42
+ }
43
+ return {
44
+ writable: [...writable].sort(),
45
+ readOnly: [...readable].filter(name => !writable.has(name)).sort(),
46
+ }
47
+ }
48
+
49
+ // Which role is in force.
50
+ //
51
+ // A principal can hold several. Naming one of them anyway would be a lie, so
52
+ // the answer is the role whose capabilities cover every other role held —
53
+ // there usually is one, because roles are written as widening tiers. When none
54
+ // dominates, the acting authority genuinely is the union and `role` is null
55
+ // with `roles` naming the parts.
56
+ export function actingRole(held = [], catalogue = {}) {
57
+ const names = (held ?? []).filter(name => catalogue[name])
58
+ if (!names.length) return null
59
+ if (names.length === 1) return names[0]
60
+ const covers = (a, b) => {
61
+ const set = new Set(catalogue[a] ?? [])
62
+ return (catalogue[b] ?? []).every(capability => set.has(capability))
63
+ }
64
+ return names.find(candidate => names.every(other => covers(candidate, other))) ?? null
65
+ }
66
+
67
+ // The roles this principal does NOT hold, and what each would add.
68
+ //
69
+ // Named so an agent can say WHO to ask. Roles are not credentials — listing
70
+ // them reveals that a `developers` role exists, which is exactly what makes a
71
+ // handoff possible, and nothing about how to obtain it.
72
+ export function otherRoles(held = [], catalogue = {}, summaries = {}) {
73
+ const mine = new Set(held ?? [])
74
+ const have = new Set((held ?? []).flatMap(name => catalogue[name] ?? []))
75
+ return Object.entries(catalogue)
76
+ .filter(([name]) => !mine.has(name))
77
+ .map(([name, capabilities]) => {
78
+ const adds = (capabilities ?? []).filter(capability => !have.has(capability))
79
+ // A role that adds nothing this principal already has is noise in
80
+ // a handoff — there is nobody to ask, because it can do no more.
81
+ if (!adds.length) return null
82
+ const reach = reachOf(adds)
83
+ return {
84
+ name,
85
+ // Expressed as collections where the capabilities allow it,
86
+ // because "layouts, styles, scripts" is what a person asking
87
+ // for help can act on and `drive:layouts:write` is not.
88
+ adds: reach.writable.length ? reach.writable : adds,
89
+ ...(summaries[name] ? { summary: summaries[name] } : {}),
90
+ }
91
+ })
92
+ .filter(Boolean)
93
+ }
94
+
95
+ // Everything a session should be able to say about its own authority.
96
+ export function describeAuthority({ capabilities, roles = [], catalogue = {}, summaries = {} } = {}) {
97
+ // No capability map configured at all: the credential is not
98
+ // capability-scoped and the endpoint's own operations are the only limit.
99
+ // Reporting a role here would invent one.
100
+ if (capabilities == null) {
101
+ return {
102
+ role: null,
103
+ roleSummary: 'This site has no roles configured, so this credential is limited only by what the '
104
+ + 'endpoint itself allows.',
105
+ capabilities: null,
106
+ writable: null,
107
+ readOnly: null,
108
+ otherRoles: [],
109
+ }
110
+ }
111
+ const role = actingRole(roles, catalogue)
112
+ const { writable, readOnly } = reachOf(capabilities)
113
+ return {
114
+ role,
115
+ ...(roles?.length && !role ? { roles } : {}),
116
+ ...(summaries[role] ? { roleSummary: summaries[role] } : {}),
117
+ writable,
118
+ readOnly,
119
+ otherRoles: otherRoles(roles, catalogue, summaries),
120
+ }
121
+ }
122
+
123
+ // The sentence an agent should repeat when a role stops it.
124
+ //
125
+ // Names the role, the capability it lacks and who has it, in that order,
126
+ // because that is the order the reader needs them: what I am, what is missing,
127
+ // who to ask. Deliberately without a suggestion to retry, escalate or work
128
+ // around it — the correct next step is a person, not another call.
129
+ export function explainRefusal({ capability, role, target, catalogue = {}, summaries = {} } = {}) {
130
+ const holders = Object.entries(catalogue)
131
+ .filter(([, capabilities]) => (capabilities ?? []).includes(capability))
132
+ .map(([name]) => name)
133
+ // Trim the summary's own full stop: it is a sentence in its own right and
134
+ // reads as a typo when a second one lands beside it.
135
+ const summary = summaries[holders[0]]?.replace(/\.\s*$/, '')
136
+ const who = holders.length
137
+ ? `The ${holders.join(' or ')} role carries it${summary ? ` — ${summary}` : ''}.`
138
+ : 'No configured role carries it.'
139
+ return [
140
+ role ? `Connected as ${role}, which does not include ${capability}.` : `This credential lacks ${capability}.`,
141
+ target ? `That is what writing to ${target} needs.` : null,
142
+ who,
143
+ 'Ask whoever set the site up; this is not something to work around.',
144
+ ].filter(Boolean).join(' ')
145
+ }