@christianirack/brainllm-runtime 0.3.1 → 0.4.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.
@@ -0,0 +1,122 @@
1
+ // Official SQLite Wasm adapter for a project-owned worker. IndexedDB remains the dependency-free default.
2
+ const G = globalThis
3
+ const fault = (code, fix) => Object.assign(new Error(fix), { code, fix })
4
+ const nameOK = x => typeof x === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/.test(x)
5
+
6
+ export function sqlite(initialize, options = {}) {
7
+ const name = options.name || 'app', namespace = options.namespace || 'app'
8
+ if (!nameOK(name) || !nameOK(namespace) || typeof initialize !== 'function') throw fault('B0916', 'sqlite: provide the official initializer and stable names')
9
+ let opening, db, pool, release, closed = false, pending = 0, chain = Promise.resolve()
10
+ const lease = new Promise(resolve => { release = resolve })
11
+ async function open() {
12
+ if (closed) throw fault('B0916', 'Local SQL handle is closed; create a new worker')
13
+ if (opening) return opening
14
+ if (G.document || !G.navigator?.storage?.getDirectory || !G.navigator?.locks?.request || typeof G.postMessage !== 'function') throw fault('B0916', 'Persistent SQLite needs an owned worker with OPFS and Web Locks; use database() when unavailable')
15
+ opening = new Promise((resolve, reject) => {
16
+ G.navigator.locks.request(`brain:sqlite:${namespace}:${name}`, { ifAvailable: true }, async lock => {
17
+ if (!lock) throw fault('B0916', 'This local SQL database is open in another worker; close its handle before retrying')
18
+ let stage = 'official SDK initialization'
19
+ try {
20
+ const engine = await initialize({ print() {}, printErr() {} })
21
+ if (closed) throw fault('B0916', 'Local SQL handle closed while initializing')
22
+ if (!engine.capi || engine.capi.sqlite3_libversion_number() < 3054000) throw fault('B0916', 'SQLite 3.54 or newer is required for non-destructive OPFS initialization')
23
+ stage = 'non-destructive OPFS initialization'
24
+ pool = await engine.installOpfsSAHPoolVfs({ name: `brain-${namespace}-${name}`, directory: `/brain-sqlite/${namespace}/${name}`, initialCapacity: 6, clearOnInit: false, preserveOnInitFailure: true })
25
+ stage = 'OPFS database open'
26
+ db = new pool.OpfsSAHPoolDb(`/${name}.sqlite3`)
27
+ stage = 'SQLite resource limits'
28
+ const c = engine.capi
29
+ for (const [constant, value] of [['SQLITE_LIMIT_LENGTH',1048576],['SQLITE_LIMIT_SQL_LENGTH',65536],['SQLITE_LIMIT_COLUMN',128],['SQLITE_LIMIT_EXPR_DEPTH',100],['SQLITE_LIMIT_COMPOUND_SELECT',16],['SQLITE_LIMIT_VARIABLE_NUMBER',128],['SQLITE_LIMIT_ATTACHED',0],['SQLITE_LIMIT_TRIGGER_DEPTH',16],['SQLITE_LIMIT_VDBE_OP',100000]]) {
30
+ if (typeof c[constant] !== 'number') throw fault('B0916', 'Official SQLite limit API is incomplete; verify the installed SDK version')
31
+ c.sqlite3_limit(db.pointer, c[constant], value)
32
+ }
33
+ db.exec('PRAGMA page_size=4096; PRAGMA cache_size=-2048; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;')
34
+ const pageSize = Number(db.selectValue('PRAGMA page_size')), pages = Number(db.selectValue('PRAGMA page_count'))
35
+ if (!Number.isSafeInteger(pageSize) || pageSize < 512 || pageSize > 65536 || pages * pageSize > 67108864) throw fault('B0914', 'Local SQL database exceeds its 64 MiB budget; archive or migrate it explicitly')
36
+ db.exec('PRAGMA max_page_count=' + Math.floor(67108864 / pageSize))
37
+ if (closed) throw fault('B0916', 'Local SQL handle closed while initializing')
38
+ resolve({ db, c })
39
+ await lease
40
+ } catch (e) {
41
+ reject(e?.code === 'B0916' ? e : fault('B0916', 'Persistent SQLite failed during ' + stage + '; existing data is preserved. Check storage capabilities and the official SDK assets'))
42
+ } finally {
43
+ try { db?.close() } catch {}
44
+ try { pool?.pauseVfs() } catch {}
45
+ db = null
46
+ }
47
+ }).catch(e => reject(e?.code === 'B0916' ? e : fault('B0916', 'Local SQL lock failed; close competing workers and retry')))
48
+ })
49
+ return opening
50
+ }
51
+ function enqueue(work) {
52
+ if (closed) return Promise.reject(fault('B0916', 'Local SQL handle is closed'))
53
+ if (pending >= 32) return Promise.reject(fault('B0914', 'Local SQL queue is full; await pending queries'))
54
+ pending++
55
+ const result = chain.then(async () => { const state = await open(); if (closed) throw fault('B0916', 'Local SQL handle is closed'); return work(state) })
56
+ chain = result.catch(() => {}).finally(() => pending--)
57
+ return result
58
+ }
59
+ function statement(state, sql, bind = [], readonly = false) {
60
+ if (typeof sql !== 'string' || !sql.trim() || new TextEncoder().encode(sql).length > 65536 || sql.includes('\0')) throw fault('B0916', 'Local SQL needs one statement of at most 64 KiB; use bound values')
61
+ if (!Array.isArray(bind) || bind.length > 128 || bind.some(v => !(v == null || ['string','number','bigint'].includes(typeof v) || v instanceof Uint8Array))) throw fault('B0916', 'Local SQL bindings must be at most 128 scalar or binary values')
62
+ let bytes = 0
63
+ for (const value of bind) {
64
+ if (typeof value === 'number' && !Number.isFinite(value)) throw fault('B0916', 'Local SQL numeric bindings must be finite')
65
+ bytes += value instanceof Uint8Array ? value.byteLength : typeof value === 'string' ? new TextEncoder().encode(value).length : 8
66
+ if (bytes > 1048576) throw fault('B0914', 'Local SQL bindings exceed 1 MiB; split the operation')
67
+ }
68
+ const leading = sql.replace(/^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/, '')
69
+ if (/^(?:begin|commit|rollback|savepoint|release|attach|detach|pragma|vacuum)\b/i.test(leading)) throw fault('B0916', 'Use native batch() for transactions; attachment, pragma and maintenance commands are not public query operations')
70
+ let stmt, phase = "prepare"
71
+ try {
72
+ stmt = state.db.prepare(sql)
73
+ phase = "statement boundary"
74
+ const consumed = state.c.sqlite3_sql(stmt.pointer)
75
+ if (typeof consumed !== 'string' || !/^[\s;]*$/.test(sql.slice(consumed.length))) throw fault('B0916', 'Use batch() for multiple SQL statements')
76
+ phase = "read-only validation"
77
+ if (readonly && !state.c.sqlite3_stmt_readonly(stmt.pointer)) throw fault('B0916', 'query() accepts read-only statements; use execute() or batch() for mutations')
78
+ phase = "binding"
79
+ if (stmt.parameterCount !== bind.length) throw fault('B0916', 'Local SQL binding count does not match the prepared statement')
80
+ if (bind.length) stmt.bind(bind)
81
+ phase = "execution"
82
+ const rows = []
83
+ let size = 0
84
+ while (stmt.step()) {
85
+ if (rows.length >= 200) throw fault('B0914', 'Local SQL returned more than 200 rows; add LIMIT and keyset pagination')
86
+ const row = stmt.get(Object.create(null))
87
+ for (const key of Object.keys(row)) {
88
+ if (typeof row[key] === 'bigint') row[key] = String(row[key])
89
+ const value = row[key]
90
+ if (typeof value === 'number' && !Number.isFinite(value)) throw fault('B0916','Local SQL returned a non-finite number; cast it to a bounded application value')
91
+ size += key.length * 2 + (value instanceof Uint8Array ? value.byteLength : typeof value === 'string' ? new TextEncoder().encode(value).length : 8)
92
+ }
93
+ if (size > 2097152) throw fault('B0914', 'Local SQL response exceeds 2 MiB; select fewer columns or rows')
94
+ rows.push(row)
95
+ }
96
+ return { rows, changes: state.db.changes(), bytes:size }
97
+ } catch (e) {
98
+ throw /^B091[46]$/.test(e?.code || '') ? e : fault('B0916', 'Local SQL operation failed during ' + phase + '; inspect the query and schema in development without exposing SQL values in production')
99
+ } finally { try { stmt?.finalize() } catch { throw fault('B0916','Local SQL statement cleanup failed; close the owned worker before retrying') } }
100
+ }
101
+ return {
102
+ query(sql, bind) { return enqueue(state => statement(state, sql, bind, true).rows) },
103
+ execute(sql, bind) { return enqueue(state => { let result; state.db.transaction(() => { result = statement(state, sql, bind) }); return result }) },
104
+ batch(operations) {
105
+ if (!Array.isArray(operations) || operations.length > 64) return Promise.reject(fault('B0914', 'Local SQL batches accept at most 64 operations'))
106
+ return enqueue(state => {
107
+ let result
108
+ state.db.transaction(() => {
109
+ let bytes=0, rows=0
110
+ result=operations.map(o => {
111
+ const value=statement(state,o.sql,o.bind); bytes+=value.bytes; rows+=value.rows.length
112
+ if (bytes>2097152 || rows>200) throw fault('B0914','Local SQL batch response exceeds 200 rows or 2 MiB; paginate the operation')
113
+ return value
114
+ })
115
+ })
116
+ return result
117
+ })
118
+ },
119
+ close() { closed = true; release(); return chain.then(() => true) },
120
+ info() { return { engine:'official-sqlite-wasm', storage:'opfs-sahpool', namespace, name, pending, closed, maxRows:200, maxDatabaseBytes:67108864 } },
121
+ }
122
+ }
@@ -0,0 +1,143 @@
1
+ // Native structured local data. Browser storage is untrusted, evictable and never an authentication authority.
2
+ const G = globalThis
3
+ const error = (code, fix, cause) => Object.assign(new Error(fix), { code, fix, cause })
4
+ const identifier = n => typeof n === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(n)
5
+
6
+ export async function storageInfo() {
7
+ const s = G.navigator?.storage
8
+ let estimate = null, persisted = false
9
+ try { estimate = s?.estimate ? await s.estimate() : null; persisted = s?.persisted ? await s.persisted() : false } catch {}
10
+ return { indexedDB: !!G.indexedDB, opfs: !!s?.getDirectory, persisted,
11
+ usage: Number.isFinite(estimate?.usage) ? estimate.usage : null,
12
+ quota: Number.isFinite(estimate?.quota) ? estimate.quota : null }
13
+ }
14
+
15
+ // Request only from a user action; browsers may refuse. Never promise permanent storage.
16
+ export async function persistStorage() {
17
+ return G.navigator?.storage?.persist ? !!(await G.navigator.storage.persist()) : false
18
+ }
19
+
20
+ export function database(name, options = {}) {
21
+ if (!identifier(name)) throw error('B0913', 'database: use a stable alphanumeric name of at most 80 characters')
22
+ const namespace = options.namespace || 'app'
23
+ if (!identifier(namespace)) throw error('B0913', 'database: use an explicit stable application namespace')
24
+ const build = options.build == null ? '' : String(options.build)
25
+ if (build.length > 128) throw error('B0913', 'database: build identifiers must be at most 128 characters')
26
+ let opening = null, connection = null, closed = false, pending = 0
27
+ const active = new Set()
28
+ const deadline = Math.max(100, Math.min(30000, Number(options.timeout) || 10000))
29
+ const fail = e => error(e?.name === 'QuotaExceededError' ? 'B0914' : 'B0913',
30
+ e?.name === 'QuotaExceededError' ? 'Local storage quota exceeded; evict owned cache records or reduce stored data' : 'Local database unavailable; keep authoritative data on the server and retry the operation', e)
31
+ function open() {
32
+ if (closed) return Promise.reject(error('B0913', 'database: connection was closed; create a new handle'))
33
+ if (opening) return opening
34
+ if (!G.indexedDB) return Promise.reject(error('B0913', 'IndexedDB is unavailable; use a server-backed data source'))
35
+ opening = new Promise((resolve, reject) => {
36
+ let settled = false
37
+ const r = G.indexedDB.open(`brain:db:${namespace}:${name}`, 1)
38
+ const timer = setTimeout(() => done(error('B0913', 'Local database open timed out; close an older tab holding an upgrade lock')), deadline)
39
+ const done = e => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } }
40
+ r.onupgradeneeded = () => { if (!r.result.objectStoreNames.contains('records')) r.result.createObjectStore('records') }
41
+ r.onblocked = () => done(error('B0913', 'Local database upgrade blocked; close older tabs and retry'))
42
+ r.onerror = () => done(fail(r.error))
43
+ r.onsuccess = () => {
44
+ if (settled || closed) { r.result.close(); return }
45
+ settled = true; clearTimeout(timer)
46
+ connection = r.result
47
+ connection.onversionchange = () => { connection?.close(); connection = null; opening = null }
48
+ connection.onclose = () => { connection = null; opening = null }
49
+ resolve(connection)
50
+ }
51
+ }).catch(e => { opening = null; throw e })
52
+ return opening
53
+ }
54
+ function key(k) {
55
+ if (typeof k !== 'string' || !k.length || k.length > 256) throw error('B0913', 'database: keys must contain between 1 and 256 characters')
56
+ return k
57
+ }
58
+ function record(value, ttl) {
59
+ if (!Number.isFinite(ttl) || ttl < 0 || ttl > 31536000000) throw error('B0913', 'database: ttl must be between zero and one year in milliseconds')
60
+ let bytes, json
61
+ try { json = JSON.stringify(value); if (json === undefined) throw new TypeError('undefined'); bytes = new TextEncoder().encode(json).length } catch (e) { throw error('B0913', 'database: values must be JSON-serializable', e) }
62
+ if (bytes > 1048576) throw error('B0914', 'database: split records larger than 1 MiB or use OPFS for large files')
63
+ return { v: 1, build, expires: ttl ? Date.now() + ttl : 0, value: JSON.parse(json) }
64
+ }
65
+ const valid = r => r?.v === 1 && r.build === build && (!r.expires || r.expires > Date.now())
66
+ async function transaction(mode, work) {
67
+ if (pending >= 32) throw error('B0914', 'database: await pending local operations before scheduling more work')
68
+ pending++
69
+ try {
70
+ const db = await open()
71
+ if (closed) throw error('B0913', 'database: handle was closed')
72
+ return await new Promise((resolve, reject) => {
73
+ const tx = db.transaction('records', mode), store = tx.objectStore('records')
74
+ active.add(tx)
75
+ let result, reason
76
+ const timer = setTimeout(() => { reason = error('B0913', 'Local transaction timed out; reduce the batch size'); try { tx.abort() } catch {} }, deadline)
77
+ tx.oncomplete = () => { clearTimeout(timer); active.delete(tx); resolve(result) }
78
+ tx.onabort = tx.onerror = () => { clearTimeout(timer); active.delete(tx); reject(reason || fail(tx.error)) }
79
+ try { work(store, v => { result = v }) } catch (e) { reason = e; try { tx.abort() } catch { clearTimeout(timer); reject(e) } }
80
+ })
81
+ } catch (e) { throw e?.code ? e : fail(e) } finally { pending-- }
82
+ }
83
+ const api = {
84
+ get(k) { key(k); return transaction('readonly', (s, done) => { const r = s.get(k); r.onsuccess = () => done(valid(r.result) ? r.result.value : null) }) },
85
+ put(k, value, o = {}) { key(k); const r = record(value, o.ttl ?? 0); return transaction('readwrite', s => s.put(r, k)) },
86
+ delete(k) { key(k); return transaction('readwrite', s => s.delete(k)) },
87
+ clear() { return transaction('readwrite', s => s.clear()) },
88
+ batch(operations) {
89
+ if (!Array.isArray(operations) || operations.length > 128) throw error('B0914', 'database: atomic batches accept at most 128 operations')
90
+ let bytes = 0
91
+ const ops = operations.map(o => {
92
+ key(o.key)
93
+ if (o.op === 'delete') return { op: 'delete', key: o.key }
94
+ if (o.op !== 'put') throw error('B0913', 'database: batch operations must be put or delete')
95
+ const r = record(o.value, o.ttl ?? 0)
96
+ bytes += new TextEncoder().encode(JSON.stringify(r)).length
97
+ if (bytes > 4194304) throw error('B0914', 'database: atomic batch exceeds 4 MiB')
98
+ return { op: 'put', key: o.key, record: r }
99
+ })
100
+ return transaction('readwrite', s => { for (const o of ops) o.op === 'put' ? s.put(o.record, o.key) : s.delete(o.key) })
101
+ },
102
+ scan(o = {}) {
103
+ const limit = Math.max(1, Math.min(200, Number(o.limit) || 50)), prefix = o.prefix || '', after = o.after || ''
104
+ if (typeof prefix !== 'string' || prefix.length > 256 || typeof after !== 'string' || after.length > 256) throw error('B0913', 'database: scan prefixes and cursors must be strings of at most 256 characters')
105
+ return transaction('readonly', (s, done) => {
106
+ const items = [], r = s.openCursor(after ? G.IDBKeyRange.lowerBound(after, true) : prefix ? G.IDBKeyRange.lowerBound(prefix) : null)
107
+ let visited = 0, bytes = 0, previous = null
108
+ r.onsuccess = () => {
109
+ const c = r.result
110
+ if (!c) return done({ items, cursor: null })
111
+ const k = String(c.key)
112
+ if (prefix && k < prefix) { c.continue(prefix); return }
113
+ if (prefix && !k.startsWith(prefix)) return done({ items, cursor: null })
114
+ visited++
115
+ if (valid(c.value)) {
116
+ bytes += new TextEncoder().encode(JSON.stringify(c.value.value)).length
117
+ if (bytes > 2097152) return done({ items, cursor: previous })
118
+ items.push({ key: k, value: c.value.value })
119
+ }
120
+ previous = k
121
+ if (items.length >= limit || visited >= 1000) return done({ items, cursor: k })
122
+ c.continue()
123
+ }
124
+ })
125
+ },
126
+ prune() {
127
+ return transaction('readwrite', (s, done) => {
128
+ let visited = 0, deleted = 0
129
+ const r = s.openCursor()
130
+ r.onsuccess = () => {
131
+ const c = r.result
132
+ if (!c || visited >= 1000) return done({ visited, deleted, truncated: !!c })
133
+ visited++
134
+ if (!valid(c.value)) { c.delete(); deleted++ }
135
+ c.continue()
136
+ }
137
+ })
138
+ },
139
+ close() { closed = true; for (const tx of active) { try { tx.abort() } catch {} }; connection?.close(); connection = null },
140
+ info: storageInfo,
141
+ }
142
+ return api
143
+ }
@@ -0,0 +1,49 @@
1
+ // Thin adapters over platform APIs. Experimental worklets are capabilities, never assumed browser support.
2
+ const G = globalThis
3
+ const fail = fix => Object.assign(new Error(fix), {code:'B0917',fix})
4
+ function local(url) {
5
+ if (!G.location) throw fail('Thread module loading requires a browser context')
6
+ const value = new URL(url,G.location.href)
7
+ if (value.origin !== G.location.origin || value.username || value.password || !/^https?:$/.test(value.protocol)) throw fail('Use a same-origin versioned module URL without credentials')
8
+ return value.href
9
+ }
10
+ export function threadCapabilities() {
11
+ return { dedicated:typeof G.Worker==='function', shared:typeof G.SharedWorker==='function',
12
+ service:!!G.navigator?.serviceWorker, messageChannel:typeof G.MessageChannel==='function',
13
+ transferableArrayBuffer:typeof G.ArrayBuffer==='function', sharedMemory:!!G.crossOriginIsolated&&typeof G.SharedArrayBuffer==='function'&&!!G.Atomics,
14
+ offscreenCanvas:typeof G.OffscreenCanvas==='function', wasm:!!G.WebAssembly,
15
+ audioWorklet:typeof G.AudioWorkletNode==='function', paintWorklet:!!G.CSS?.paintWorklet?.addModule,
16
+ layoutWorklet:!!G.CSS?.layoutWorklet?.addModule, animationWorklet:!!G.CSS?.animationWorklet?.addModule,
17
+ indexedDB:!!G.indexedDB, opfs:!!G.navigator?.storage?.getDirectory,
18
+ backgroundSync:!!G.ServiceWorkerRegistration?.prototype&&'sync' in G.ServiceWorkerRegistration.prototype,
19
+ push:typeof G.PushManager==='function' }
20
+ }
21
+ export async function loadWorklet(kind,url,options={}) {
22
+ const target = kind==='audio' ? options.context?.audioWorklet : kind==='paint' ? G.CSS?.paintWorklet : kind==='layout' ? G.CSS?.layoutWorklet : kind==='animation' ? G.CSS?.animationWorklet : null
23
+ if (!target?.addModule) {
24
+ if (typeof options.fallback==='function') return options.fallback()
25
+ throw fail('Requested worklet is unavailable; supply an explicit rendering/audio fallback')
26
+ }
27
+ // addModule has no portable cancellation. Bound outstanding requests and avoid spawning an AudioContext implicitly.
28
+ if (loading>=4) throw fail('Too many worklet loads; await existing modules')
29
+ loading++
30
+ try { await target.addModule(local(url),{credentials:'omit'}); return {available:true,kind} }
31
+ catch { throw fail('Worklet module could not load; check its same-origin asset URL and production CSP') }
32
+ finally { loading-- }
33
+ }
34
+ let loading=0
35
+ export function messageChannel() {
36
+ if (!G.MessageChannel) throw fail('MessageChannel is unavailable in this context')
37
+ const c=new G.MessageChannel()
38
+ return {port1:c.port1,port2:c.port2,close(){c.port1.close();c.port2.close()}}
39
+ }
40
+ export function offscreen(canvas) {
41
+ if (!canvas?.transferControlToOffscreen) throw fail('OffscreenCanvas transfer is unavailable; keep the canvas on the main thread')
42
+ try { return canvas.transferControlToOffscreen() }
43
+ catch { throw fail('Canvas was already initialized or transferred; transfer it before acquiring a rendering context') }
44
+ }
45
+ export function sharedMemory(bytes) {
46
+ if (!threadCapabilities().sharedMemory) throw fail('Shared memory needs cross-origin isolation and browser support; use transferable ArrayBuffers otherwise')
47
+ if (!Number.isSafeInteger(bytes)||bytes<1||bytes>8388608) throw fail('Shared memory buffers must contain between 1 byte and 8 MiB')
48
+ return new G.SharedArrayBuffer(bytes)
49
+ }
package/brain-time.js ADDED
@@ -0,0 +1,77 @@
1
+ // Pure native date operations. Calendar arithmetic is UTC; timezone projection never changes the instant.
2
+ const MS = { ms: 1, second: 1000, minute: 60000, hour: 3600000, day: 86400000, week: 604800000 }
3
+ let zones
4
+ const monthDays = (y, m) => m === 1 ? (y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28) : [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m]
5
+ export function dateParse(value) {
6
+ if (value instanceof Date) value = value.getTime()
7
+ if (typeof value === 'number') {
8
+ const instant = new Date(value).getTime()
9
+ if (!Number.isFinite(value) || !Number.isFinite(instant)) throw RangeError('dateParse: invalid epoch milliseconds')
10
+ return instant
11
+ }
12
+ if (typeof value !== 'string') throw TypeError('dateParse: use epoch milliseconds, Date or an ISO date')
13
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2}))?$/.exec(value)
14
+ if (!match) throw RangeError('dateParse: datetime strings require an explicit timezone')
15
+ const y = +match[1], m = +match[2], d = +match[3]
16
+ if (m < 1 || m > 12 || d < 1 || d > monthDays(y, m - 1)) throw RangeError('dateParse: invalid calendar date')
17
+ const result = Date.parse(value)
18
+ if (!Number.isFinite(result)) throw RangeError('dateParse: invalid ISO date')
19
+ return result
20
+ }
21
+ export function dateAdd(value, amount, unit = 'day') {
22
+ const start = dateParse(value)
23
+ if (!Number.isFinite(amount)) throw RangeError('dateAdd: amount must be finite')
24
+ if (Object.hasOwn(MS, unit)) return dateParse(start + amount * MS[unit])
25
+ if (!['month', 'year'].includes(unit) || !Number.isSafeInteger(amount)) throw RangeError('dateAdd: use a supported unit and integer calendar amount')
26
+ const date = new Date(start), day = date.getUTCDate()
27
+ const months = date.getUTCFullYear() * 12 + date.getUTCMonth() + (unit === 'month' ? amount : 0)
28
+ const year = Math.floor(months / 12) + (unit === 'year' ? amount : 0), month = months - Math.floor(months / 12) * 12
29
+ // Set the complete calendar date once: day 1 or the end of a month can be outside Date's valid range.
30
+ date.setUTCFullYear(year, month, Math.min(day, monthDays(year, month)))
31
+ if (!Number.isFinite(date.getTime())) throw RangeError('dateAdd: calendar overflow')
32
+ return dateParse(date)
33
+ }
34
+ export function dateDiff(left, right, unit = 'day') {
35
+ if (!Object.hasOwn(MS, unit)) throw RangeError('dateDiff: elapsed units are ms, second, minute, hour, day or week')
36
+ return (dateParse(left) - dateParse(right)) / MS[unit]
37
+ }
38
+ export function dateRange(start, end, options = {}) {
39
+ let cursor = dateParse(start)
40
+ const stop = dateParse(end), step = options.step ?? 1, unit = options.unit ?? 'day', limit = options.limit ?? 1000
41
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10000 || !Number.isFinite(step) || step === 0) throw RangeError('dateRange: nonzero step and limit 1..10000 required')
42
+ if (!Object.hasOwn(MS, unit) && !(['month', 'year'].includes(unit) && Number.isSafeInteger(step))) throw RangeError('dateRange: supported unit and integer calendar step required')
43
+ if ((stop - cursor) * step < 0) throw RangeError('dateRange: step must point toward the end')
44
+ const out = []
45
+ while (step > 0 ? cursor <= stop : cursor >= stop) {
46
+ if (out.length === limit) throw RangeError('dateRange: result exceeds limit')
47
+ out.push(cursor)
48
+ if (cursor === stop) break
49
+ const next = dateAdd(cursor, step, unit)
50
+ if (step > 0 ? next <= cursor : next >= cursor) throw RangeError('dateRange: step does not advance')
51
+ cursor = next
52
+ }
53
+ return out
54
+ }
55
+ export function dateZone(value, timeZone) {
56
+ const instant = dateParse(value)
57
+ if (typeof timeZone !== 'string' || !timeZone || timeZone.length > 128 || /^[+-]/.test(timeZone)) throw TypeError('dateZone: explicit IANA timezone required')
58
+ zones ||= new Map()
59
+ let formatter = zones.get(timeZone)
60
+ if (!formatter) {
61
+ formatter = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { timeZone, era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
62
+ if (zones.size === 16) zones.delete(zones.keys().next().value)
63
+ zones.set(timeZone, formatter)
64
+ }
65
+ const parts = {}
66
+ for (const part of formatter.formatToParts(instant)) if (part.type !== 'literal') parts[part.type] = part.type === 'era' ? part.value : Number(part.value)
67
+ if (parts.era === 'BC') parts.year = 1 - parts.year
68
+ // Gregorian calendars repeat every 400 years. Project both calendar dates into the same safe cycle,
69
+ // preserving the difference even when the local wall clock lies beyond Date's global epoch bounds.
70
+ const reference = new Date(instant), cycle = Math.floor(reference.getUTCFullYear() / 400) * 400
71
+ reference.setUTCFullYear(reference.getUTCFullYear() - cycle)
72
+ reference.setUTCMilliseconds(0)
73
+ const local = new Date(0)
74
+ local.setUTCFullYear(parts.year - cycle, parts.month - 1, parts.day)
75
+ local.setUTCHours(parts.hour, parts.minute, parts.second, 0)
76
+ return { timeZone, instant, year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second, offsetMinutes: (local.getTime() - reference.getTime()) / 60000 }
77
+ }
package/brain-url.js ADDED
@@ -0,0 +1,125 @@
1
+ // Shareable public navigation state. Credentials, form contents and transient busy/error state do not belong in URLs.
2
+ import { sig, fx, _rt, untrack } from './brain.js'
3
+ const G = globalThis, I = _rt(), bindings = new Map(), known = new Set()
4
+ const privateKey = /password|secret|token|authorization|session|csrf|jwt|email|credit/i
5
+ let listening = false, reports = 0
6
+ const report = fix => { if (reports++ < 32) { const e = Object.assign(new Error(fix), { code: 'B0915', fix }); G.reportError ? G.reportError(e) : console.warn(e.message) } }
7
+ const scalar = v => typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))
8
+ const safe = v => scalar(v) && String(v).length <= 512 && !/[\u0000-\u001f\u007f]/.test(String(v)) && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))
9
+ const encode = v => Array.isArray(v) ? (v.length <= 32 && v.every(safe) ? JSON.stringify(v) : null) : safe(v) ? String(v) : null
10
+ function decode(raw, base) {
11
+ if (raw == null) return base
12
+ let v = raw
13
+ if (typeof base === 'boolean') { if (!/^(true|false)$/.test(raw)) return base; v = raw === 'true' }
14
+ else if (typeof base === 'number') { if (!/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) return base; v = Number(raw) }
15
+ else if (Array.isArray(base)) { try { v = JSON.parse(raw) } catch { return base }; if (!Array.isArray(v) || (base.length && v.some(x => typeof x !== typeof base[0]))) return base }
16
+ return encode(v) == null ? base : v
17
+ }
18
+ function restore() {
19
+ if (!G.location) return
20
+ const url = new URL(G.location.href)
21
+ for (const [key, b] of bindings) if (b.path === url.pathname) {
22
+ const value = decode(url.searchParams.get(key), b.base)
23
+ b.last = encode(value)
24
+ b.cell.v = value
25
+ }
26
+ G.__brainRouted?.()
27
+ }
28
+ G.__brainURLOnly = (from, to) => {
29
+ try {
30
+ const a = new URL(from, G.location.href), b = new URL(to, G.location.href)
31
+ if (a.origin !== b.origin || a.pathname !== b.pathname || a.hash !== b.hash) return false
32
+ if (a.href === b.href) return true
33
+ for (const key of known) { a.searchParams.delete(key); b.searchParams.delete(key) }
34
+ return a.href === b.href
35
+ } catch { return false }
36
+ }
37
+ G.__brainURLRestore = restore
38
+
39
+ export function urlState(initial, options) {
40
+ const cell = sig(initial)
41
+ if (!options?.disabled) urlBind(cell, options?.key, options)
42
+ return cell
43
+ }
44
+
45
+ // A native UI primitive stays stateless in author source; its DOM adapter owns navigation state and cleanup.
46
+ export function urlDialog(element, key) {
47
+ if (!element || !key || G.__brainSSR || G.__brainRoute || !G.location) return
48
+ return I.root(dispose => {
49
+ let alive = true
50
+ const opened = urlState(!!element.open, {key})
51
+ const read = () => { if (alive) opened.v = !!element.open }
52
+ const apply = value => {
53
+ if (!alive || !element.isConnected) return
54
+ if (value && !element.open) element.showModal()
55
+ else if (!value && element.open) element.close()
56
+ }
57
+ fx(() => {
58
+ const value = opened.v
59
+ if (element.isConnected) apply(value)
60
+ else queueMicrotask(() => apply(value))
61
+ })
62
+ const observer = G.MutationObserver ? new G.MutationObserver(read) : null
63
+ observer?.observe(element, {attributes:true,attributeFilter:['open']})
64
+ element.addEventListener('close',read)
65
+ element.addEventListener('toggle',read)
66
+ fx(() => () => {
67
+ alive = false
68
+ observer?.disconnect()
69
+ element.removeEventListener('close',read)
70
+ element.removeEventListener('toggle',read)
71
+ })
72
+ return dispose
73
+ }, I.owner())
74
+ }
75
+
76
+ // Internal compiler binding for public select/tab/modal signals, once during component setup.
77
+ export function urlBind(cell, key, options = {}) {
78
+ if (G.__brainRoute || G.__brainSSR) {
79
+ if (typeof key === 'string' && key.length <= 200 && !privateKey.test(key) && encode(untrack(() => cell.v)) != null) {
80
+ const raw = G.__brainRoute?.query?.[key]
81
+ cell.v = decode(typeof raw === 'string' ? raw : null, untrack(() => cell.v))
82
+ }
83
+ return cell
84
+ }
85
+ if (!G.location || !G.history) return cell
86
+ if (typeof key !== 'string' || !key.length || key.length > 200 || privateKey.test(key) || encode(untrack(() => cell.v)) == null) {
87
+ report('URL state needs a stable public key and bounded scalar value; keep credentials and form contents in private state')
88
+ return cell
89
+ }
90
+ const owner = I.owner()
91
+ if (!owner) { report('URL state must belong to a component so its listeners can be disposed'); return cell }
92
+ const previous = bindings.get(key)
93
+ if (previous) {
94
+ if (previous.cell !== cell) report('Repeated URL state key; give repeated components distinct stable urlState keys')
95
+ return cell
96
+ }
97
+ if (bindings.size >= 128 || known.size >= 512) { report('URL state key capacity exceeded; reduce public state or reuse stable keys'); return cell }
98
+ const base = untrack(() => cell.v), url = new URL(G.location.href)
99
+ if (url.search.length > 8192) { report('URL state exceeds 8 KiB; shorten public selections'); return cell }
100
+ cell.v = decode(url.searchParams.get(key), base)
101
+ const b = { cell, base, path: url.pathname, last: encode(cell.v) }
102
+ bindings.set(key, b); known.add(key)
103
+ if (!listening) { G.addEventListener('popstate', restore); listening = true }
104
+ fx(() => {
105
+ const value = encode(cell.v)
106
+ if (value === b.last) return
107
+ b.last = value
108
+ if (value == null) { report('URL state value is private, unsupported or too large; keep it out of navigation state'); return }
109
+ const next = new URL(G.location.href)
110
+ if (next.pathname !== b.path) return
111
+ if (value === encode(base)) next.searchParams.delete(key)
112
+ else next.searchParams.set(key, value)
113
+ if (next.search.length > 8192) { report('URL state exceeds 8 KiB; shorten public selections'); return }
114
+ if (next.href === G.location.href) return
115
+ try {
116
+ G.history[options.history === 'replace' ? 'replaceState' : 'pushState'](G.history.state, '', next.href)
117
+ G.__brainRouted?.()
118
+ } catch { report('URL state history write failed; reduce the frequency of changes or use history: replace') }
119
+ })
120
+ fx(() => () => {
121
+ if (bindings.get(key) === b) bindings.delete(key)
122
+ if (!bindings.size && listening) { G.removeEventListener('popstate', restore); listening = false }
123
+ })
124
+ return cell
125
+ }