@storion/storion 1.0.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,471 @@
1
+ /**
2
+ * Database class: one logical database in a given storage.
3
+ * All methods are async when using IndexedDB; sync when using localStorage/sessionStorage.
4
+ */
5
+
6
+ import { getStorageAdapter } from './storage/adapters.js';
7
+ import { executeQuery, validateQuery } from './queryEngine.js';
8
+ import {
9
+ normalizeColumn,
10
+ getColumnNames,
11
+ getColumnType,
12
+ coerceValue,
13
+ parseConfig
14
+ } from './schema.js';
15
+
16
+ function referencedValueExists(db, dbName, refTable, refCol, value) {
17
+ if (!db.databases[dbName] || !db.databases[dbName].tables[refTable]) return false;
18
+ const rows = db.databases[dbName].tables[refTable].rows || [];
19
+ return rows.some(r => r[refCol] != null && String(r[refCol]) === String(value));
20
+ }
21
+
22
+ /**
23
+ * Create or connect to a database.
24
+ * @param {Object} options
25
+ * @param {string} options.name - Database name
26
+ * @param {string} options.storage - 'localStorage' | 'sessionStorage' | 'indexedDB'
27
+ * @param {Object} [options.config] - Optional config object to create DB and tables from (see docs/CONFIG_FORMAT.md)
28
+ * @param {string} [options.storageKey] - Optional key to store data under (default: __LS_DB__)
29
+ * @returns {Promise<Database>} Database instance
30
+ */
31
+ export async function createDatabase(options) {
32
+ const { name, storage, config, storageKey } = options || {};
33
+ if (!name || typeof name !== 'string' || !name.trim()) {
34
+ throw new Error('Database name is required');
35
+ }
36
+ if (!storage || !['localStorage', 'sessionStorage', 'indexedDB'].includes(storage)) {
37
+ throw new Error('storage must be one of: localStorage, sessionStorage, indexedDB');
38
+ }
39
+
40
+ const adapter = getStorageAdapter(storage, storageKey);
41
+ const db = new Database(name, adapter);
42
+ await db._load();
43
+ if (!db._data.databases[db.name]) {
44
+ db._data.databases[db.name] = { tables: {} };
45
+ await db._save();
46
+ }
47
+
48
+ if (config) {
49
+ const parsed = parseConfig(config, name);
50
+ if (parsed.databases[name]) {
51
+ const def = parsed.databases[name];
52
+ for (const [tableName, tableDef] of Object.entries(def.tables || {})) {
53
+ if (tableDef.columns && tableDef.columns.length > 0) {
54
+ await db.createTable(tableName, tableDef.columns);
55
+ }
56
+ }
57
+ } else {
58
+ for (const [dbName, def] of Object.entries(parsed.databases)) {
59
+ for (const [tableName, tableDef] of Object.entries(def.tables || {})) {
60
+ if (tableDef.columns && tableDef.columns.length > 0 && dbName === name) {
61
+ await db.createTable(tableName, tableDef.columns);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ } else {
67
+ if (!db._data.databases[name]) {
68
+ db._data.databases[name] = { tables: {} };
69
+ await db._save();
70
+ }
71
+ }
72
+
73
+ return db;
74
+ }
75
+
76
+ /**
77
+ * Load config from a URL (e.g. /config/db.json). Use in browser or with fetch.
78
+ * @param {string} url - URL to fetch JSON from
79
+ * @returns {Promise<Object>} Config object
80
+ */
81
+ export async function loadConfigFromUrl(url) {
82
+ const res = await fetch(url);
83
+ if (!res.ok) throw new Error(`Failed to load config: ${res.status} ${res.statusText}`);
84
+ return res.json();
85
+ }
86
+
87
+ /**
88
+ * Load config from a File object (e.g. from input[type=file]). Browser only.
89
+ * @param {File} file - File object
90
+ * @returns {Promise<Object>} Config object
91
+ */
92
+ export async function loadConfigFromFile(file) {
93
+ if (!file || typeof file.text !== 'function') {
94
+ throw new Error('loadConfigFromFile expects a File object (e.g. from input[type=file])');
95
+ }
96
+ const text = await file.text();
97
+ return JSON.parse(text);
98
+ }
99
+
100
+ class Database {
101
+ constructor(name, adapter) {
102
+ this.name = name;
103
+ this._adapter = adapter;
104
+ this._isAsync = !!adapter.isAsync;
105
+ this._data = { databases: {} };
106
+ this._subscribers = [];
107
+ this._nextSubId = 0;
108
+ this._changeBroadcaster = null;
109
+ }
110
+
111
+ _emitChange(event) {
112
+ if (this._subscribers.length === 0 && !this._changeBroadcaster) return;
113
+ const payload = {
114
+ type: event.type,
115
+ dbName: this.name,
116
+ tableName: event.tableName,
117
+ ...(event.row != null && { row: event.row }),
118
+ ...(event.rowId != null && { rowId: event.rowId }),
119
+ ...(event.previousRow != null && { previousRow: event.previousRow })
120
+ };
121
+ for (const sub of this._subscribers) {
122
+ if (sub.tableName != null && sub.tableName !== event.tableName) continue;
123
+ if (sub.rowId != null && (event.rowId == null || String(event.rowId) !== String(sub.rowId))) continue;
124
+ try {
125
+ sub.callback(payload);
126
+ } catch (err) {
127
+ if (typeof console !== 'undefined' && console.error) {
128
+ console.error('[Storion] subscriber callback error:', err);
129
+ }
130
+ }
131
+ }
132
+ if (this._changeBroadcaster && typeof this._changeBroadcaster.broadcastChange === 'function') {
133
+ try {
134
+ const result = this._changeBroadcaster.broadcastChange(payload);
135
+ if (result && typeof result.catch === 'function') result.catch(() => {});
136
+ } catch (err) {
137
+ if (typeof console !== 'undefined' && console.error) {
138
+ console.error('[Storion] broadcaster error:', err);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Subscribe to change events. Overloads:
146
+ * - subscribe(callback) — all changes in this database
147
+ * - subscribe(tableName, callback) — changes for one table
148
+ * - subscribe(tableName, rowId, callback) — changes for one row
149
+ * @returns {function()} unsubscribe function
150
+ */
151
+ subscribe(tableNameOrCallback, rowIdOrCallback, maybeCallback) {
152
+ let tableName = null;
153
+ let rowId = null;
154
+ let callback;
155
+ if (typeof tableNameOrCallback === 'function') {
156
+ callback = tableNameOrCallback;
157
+ } else if (typeof rowIdOrCallback === 'function') {
158
+ tableName = tableNameOrCallback;
159
+ callback = rowIdOrCallback;
160
+ } else {
161
+ tableName = tableNameOrCallback;
162
+ rowId = rowIdOrCallback;
163
+ callback = maybeCallback;
164
+ }
165
+ if (typeof callback !== 'function') {
166
+ throw new Error('subscribe requires a callback function');
167
+ }
168
+ const id = ++this._nextSubId;
169
+ this._subscribers.push({ id, tableName, rowId, callback });
170
+ return () => this.unsubscribe(id);
171
+ }
172
+
173
+ /**
174
+ * Remove a subscription by id (or by the function returned from subscribe).
175
+ * @param {number} id - subscription id returned from subscribe (or use the returned unsubscribe function)
176
+ */
177
+ unsubscribe(id) {
178
+ this._subscribers = this._subscribers.filter(s => s.id !== id);
179
+ }
180
+
181
+ /**
182
+ * Set an optional broadcaster for cross-context sync (e.g. Phase 2: extension ↔ webapp).
183
+ * @param {{ broadcastChange: function(object): void|Promise }} broadcaster - object with broadcastChange(event)
184
+ */
185
+ setChangeBroadcaster(broadcaster) {
186
+ this._changeBroadcaster = broadcaster || null;
187
+ }
188
+
189
+ async _load() {
190
+ const raw = this._isAsync ? await this._adapter.getItem() : this._adapter.getItem();
191
+ const data = raw ? JSON.parse(raw) : { databases: {} };
192
+ this._data = data;
193
+ if (!this._data.databases[this.name]) {
194
+ this._data.databases[this.name] = { tables: {} };
195
+ }
196
+ }
197
+
198
+ async _save() {
199
+ const json = JSON.stringify(this._data);
200
+ if (this._isAsync) {
201
+ await this._adapter.setItem(null, json);
202
+ } else {
203
+ this._adapter.setItem(null, json);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Create a table.
209
+ * @param {string} tableName
210
+ * @param {Array<string|{name: string, type: string}>} columns - e.g. [{ name: 'id', type: 'int' }, { name: 'title', type: 'string' }]
211
+ * @returns {Promise<boolean>}
212
+ */
213
+ async createTable(tableName, columns) {
214
+ await this._load();
215
+ const db = this._data.databases[this.name];
216
+ if (!db) throw new Error(`Database "${this.name}" does not exist`);
217
+ if (db.tables[tableName]) throw new Error(`Table "${tableName}" already exists`);
218
+
219
+ if (!Array.isArray(columns) || columns.length === 0) {
220
+ throw new Error('columns must be a non-empty array');
221
+ }
222
+
223
+ const normalized = columns.map(c => normalizeColumn(c)).filter(Boolean);
224
+ const names = normalized.map(c => c.name);
225
+ if (!names.includes('id')) {
226
+ normalized.unshift({ name: 'id', type: 'int' });
227
+ } else {
228
+ const idCol = normalized.find(c => c.name === 'id');
229
+ if (idCol) idCol.type = 'int';
230
+ }
231
+
232
+ db.tables[tableName] = { columns: normalized, rows: [] };
233
+ await this._save();
234
+ this._emitChange({ type: 'tableCreated', tableName });
235
+ return true;
236
+ }
237
+
238
+ /**
239
+ * List table names.
240
+ * @returns {Promise<string[]>}
241
+ */
242
+ async listTables() {
243
+ await this._load();
244
+ const db = this._data.databases[this.name];
245
+ return db ? Object.keys(db.tables || {}) : [];
246
+ }
247
+
248
+ /**
249
+ * Get table structure and rows.
250
+ * @param {string} tableName
251
+ * @returns {Promise<{ columns: Array, rows: Array }>}
252
+ */
253
+ async getTable(tableName) {
254
+ await this._load();
255
+ const db = this._data.databases[this.name];
256
+ if (!db || !db.tables[tableName]) {
257
+ throw new Error(`Table "${tableName}" does not exist`);
258
+ }
259
+ const table = db.tables[tableName];
260
+ const columns = (table.columns || []).map(c => normalizeColumn(c)).filter(Boolean);
261
+ return { columns, rows: [...(table.rows || [])] };
262
+ }
263
+
264
+ /**
265
+ * Insert a row. ID is auto-generated if omitted.
266
+ * @param {string} tableName
267
+ * @param {Object} row
268
+ * @returns {Promise<Object>} Inserted row
269
+ */
270
+ async insert(tableName, row) {
271
+ await this._load();
272
+ const db = this._data.databases[this.name];
273
+ if (!db || !db.tables[tableName]) throw new Error(`Table "${tableName}" does not exist`);
274
+
275
+ const table = db.tables[tableName];
276
+ const columnNames = getColumnNames(table.columns);
277
+ const coerced = {};
278
+ for (const colName of columnNames) {
279
+ const type = getColumnType(table.columns, colName);
280
+ if (Object.prototype.hasOwnProperty.call(row, colName)) {
281
+ coerced[colName] = coerceValue(row[colName], type);
282
+ }
283
+ }
284
+ if (coerced.id === undefined || coerced.id === null) {
285
+ const rows = table.rows || [];
286
+ const maxId = rows.length > 0
287
+ ? Math.max(...rows.map(r => (r.id != null ? Number(r.id) : 0)))
288
+ : 0;
289
+ coerced.id = maxId + 1;
290
+ }
291
+ const invalidKeys = Object.keys(coerced).filter(k => !columnNames.includes(k));
292
+ if (invalidKeys.length > 0) throw new Error(`Invalid columns: ${invalidKeys.join(', ')}`);
293
+
294
+ const normalizedCols = (table.columns || []).map(c => normalizeColumn(c)).filter(Boolean);
295
+ for (const col of normalizedCols) {
296
+ if (!col.references) continue;
297
+ const val = coerced[col.name];
298
+ if (val === null || val === undefined) continue;
299
+ const { table: refTable, column: refCol } = col.references;
300
+ if (!referencedValueExists(this._data, this.name, refTable, refCol, val)) {
301
+ throw new Error(`Foreign key violation: value ${val} not found in ${refTable}.${refCol}`);
302
+ }
303
+ }
304
+
305
+ table.rows = table.rows || [];
306
+ table.rows.push({ ...coerced });
307
+ await this._save();
308
+ this._emitChange({ type: 'insert', tableName, row: { ...coerced } });
309
+ return coerced;
310
+ }
311
+
312
+ /**
313
+ * Fetch rows from a table. Optional simple filter and sort.
314
+ * @param {string} tableName
315
+ * @param {Object} [options] - { filter: { col: value }, sortBy: string, sortOrder: 'asc'|'desc', limit: number }
316
+ * @returns {Promise<Object[]>}
317
+ */
318
+ async fetch(tableName, options = {}) {
319
+ const { columns, rows } = await this.getTable(tableName);
320
+ let result = [...rows];
321
+ if (options.filter && typeof options.filter === 'object') {
322
+ result = result.filter(row =>
323
+ Object.entries(options.filter).every(([k, v]) =>
324
+ String(row[k] || '').toLowerCase().includes(String(v || '').toLowerCase())
325
+ )
326
+ );
327
+ }
328
+ if (options.sortBy) {
329
+ const dir = options.sortOrder === 'desc' ? -1 : 1;
330
+ result.sort((a, b) => {
331
+ const aVal = a[options.sortBy] ?? '';
332
+ const bVal = b[options.sortBy] ?? '';
333
+ return aVal > bVal ? dir : aVal < bVal ? -dir : 0;
334
+ });
335
+ }
336
+ if (options.limit != null) result = result.slice(0, options.limit);
337
+ return result;
338
+ }
339
+
340
+ /**
341
+ * Run a JSON query on a table (where, orderBy, limit, offset). Uses the built-in query engine.
342
+ * @param {string} tableName
343
+ * @param {Object} query - { where?, orderBy?, limit?, offset? }
344
+ * @returns {Promise<{ rows: Object[], totalCount: number }>}
345
+ */
346
+ async query(tableName, query) {
347
+ const { columns, rows } = await this.getTable(tableName);
348
+ const validation = validateQuery(query, columns);
349
+ if (!validation.valid) throw new Error(validation.error);
350
+ return executeQuery(rows, columns, query);
351
+ }
352
+
353
+ /**
354
+ * Update a row by id.
355
+ * @param {string} tableName
356
+ * @param {number|string} id
357
+ * @param {Object} newData
358
+ * @returns {Promise<Object>}
359
+ */
360
+ async update(tableName, id, newData) {
361
+ await this._load();
362
+ const db = this._data.databases[this.name];
363
+ if (!db || !db.tables[tableName]) throw new Error(`Table "${tableName}" does not exist`);
364
+
365
+ const rows = db.tables[tableName].rows || [];
366
+ const table = db.tables[tableName];
367
+ const idx = rows.findIndex(r => r.id == id);
368
+ if (idx === -1) throw new Error(`Row with id ${id} not found`);
369
+ if (newData.id != null && newData.id != id) throw new Error('Cannot change row id');
370
+
371
+ const coerced = {};
372
+ for (const key of Object.keys(newData)) {
373
+ const type = getColumnType(table.columns, key);
374
+ coerced[key] = coerceValue(newData[key], type);
375
+ }
376
+ const normalizedCols = (table.columns || []).map(c => normalizeColumn(c)).filter(Boolean);
377
+ for (const col of normalizedCols) {
378
+ if (!col.references || !Object.prototype.hasOwnProperty.call(coerced, col.name)) continue;
379
+ const val = coerced[col.name];
380
+ if (val === null || val === undefined) continue;
381
+ const { table: refTable, column: refCol } = col.references;
382
+ if (!referencedValueExists(this._data, this.name, refTable, refCol, val)) {
383
+ throw new Error(`Foreign key violation: value ${val} not found in ${refTable}.${refCol}`);
384
+ }
385
+ }
386
+ const previousRow = { ...rows[idx] };
387
+ Object.assign(rows[idx], coerced);
388
+ await this._save();
389
+ this._emitChange({
390
+ type: 'update',
391
+ tableName,
392
+ rowId: id,
393
+ row: { ...rows[idx] },
394
+ previousRow
395
+ });
396
+ return rows[idx];
397
+ }
398
+
399
+ /**
400
+ * Delete a row by id.
401
+ * @param {string} tableName
402
+ * @param {number|string} id
403
+ * @returns {Promise<boolean>}
404
+ */
405
+ async delete(tableName, id) {
406
+ await this._load();
407
+ const db = this._data.databases[this.name];
408
+ if (!db || !db.tables[tableName]) throw new Error(`Table "${tableName}" does not exist`);
409
+
410
+ const tables = db.tables;
411
+ const refCol = 'id';
412
+ for (const [otherName, otherTable] of Object.entries(tables)) {
413
+ if (otherName === tableName) continue;
414
+ const cols = otherTable.columns || [];
415
+ for (const c of cols) {
416
+ const col = typeof c === 'object' && c && c.references ? c : normalizeColumn(c);
417
+ if (!col || !col.references || col.references.table !== tableName || col.references.column !== refCol) continue;
418
+ const otherRows = otherTable.rows || [];
419
+ if (otherRows.some(r => r[col.name] != null && String(r[col.name]) === String(id))) {
420
+ throw new Error(`Cannot delete: row is referenced by ${otherName}.${col.name}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ const rows = db.tables[tableName].rows || [];
426
+ const deletedRow = rows.find(r => r.id == id);
427
+ if (!deletedRow) throw new Error(`Row with id ${id} not found`);
428
+ const previousRow = { ...deletedRow };
429
+ db.tables[tableName].rows = rows.filter(r => r.id != id);
430
+ await this._save();
431
+ this._emitChange({ type: 'delete', tableName, rowId: id, previousRow });
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * Delete a table.
437
+ * @param {string} tableName
438
+ * @returns {Promise<boolean>}
439
+ */
440
+ async deleteTable(tableName) {
441
+ await this._load();
442
+ const db = this._data.databases[this.name];
443
+ if (!db || !db.tables[tableName]) throw new Error(`Table "${tableName}" does not exist`);
444
+
445
+ const tables = db.tables;
446
+ for (const [otherName, otherTable] of Object.entries(tables)) {
447
+ if (otherName === tableName) continue;
448
+ const cols = otherTable.columns || [];
449
+ for (const c of cols) {
450
+ const col = typeof c === 'object' && c && c.references ? c : normalizeColumn(c);
451
+ if (col && col.references && col.references.table === tableName) {
452
+ throw new Error(`Cannot delete table: it is referenced by ${otherName}.${col.name}`);
453
+ }
454
+ }
455
+ }
456
+ delete db.tables[tableName];
457
+ await this._save();
458
+ this._emitChange({ type: 'tableDeleted', tableName });
459
+ return true;
460
+ }
461
+
462
+ /**
463
+ * Export full in-memory state (this DB only) as JSON.
464
+ * @returns {Promise<Object>}
465
+ */
466
+ async exportConfig() {
467
+ await this._load();
468
+ const db = this._data.databases[this.name];
469
+ return db ? { databases: { [this.name]: { tables: db.tables || {} } } } : { databases: {} };
470
+ }
471
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Helper to listen for Storion change events coming from another context.
3
+ * The transport is user-provided and must expose an onMessage(handler) method
4
+ * that registers a callback and returns an unsubscribe function.
5
+ *
6
+ * Example transport shape (conceptual):
7
+ * {
8
+ * onMessage(handler) {
9
+ * // call handler(message) whenever a message arrives
10
+ * // return an unsubscribe function
11
+ * }
12
+ * }
13
+ *
14
+ * Messages passed to the handler are expected to already be decoded
15
+ * StorionChangeEvent-like objects.
16
+ */
17
+ export function createChangeListener(transport, onChange) {
18
+ if (!transport || typeof transport.onMessage !== 'function') {
19
+ throw new Error('createChangeListener requires a transport with onMessage(callback)');
20
+ }
21
+ if (typeof onChange !== 'function') {
22
+ throw new Error('createChangeListener requires an onChange callback');
23
+ }
24
+
25
+ const allowedTypes = new Set([
26
+ 'insert',
27
+ 'update',
28
+ 'delete',
29
+ 'tableCreated',
30
+ 'tableDeleted'
31
+ ]);
32
+
33
+ function isValidChangeEvent(event) {
34
+ if (!event || typeof event !== 'object') return false;
35
+ const { type, dbName, tableName } = event;
36
+ if (!allowedTypes.has(type)) return false;
37
+ if (typeof dbName !== 'string' || !dbName) return false;
38
+ if (typeof tableName !== 'string' || !tableName) return false;
39
+ return true;
40
+ }
41
+
42
+ const handler = (message) => {
43
+ const event = message;
44
+ if (!isValidChangeEvent(event)) {
45
+ // Silently ignore non-Storion messages so the same transport can be shared.
46
+ return;
47
+ }
48
+ try {
49
+ // Shallow clone to avoid accidental external mutation of internal state.
50
+ const cloned = {
51
+ type: event.type,
52
+ dbName: event.dbName,
53
+ tableName: event.tableName
54
+ };
55
+ if (event.row != null) cloned.row = event.row;
56
+ if (event.rowId != null) cloned.rowId = event.rowId;
57
+ if (event.previousRow != null) cloned.previousRow = event.previousRow;
58
+ onChange(cloned);
59
+ } catch (err) {
60
+ if (typeof console !== 'undefined' && console.error) {
61
+ console.error('[Storion] createChangeListener onChange error:', err);
62
+ }
63
+ }
64
+ };
65
+
66
+ const unsubscribe = transport.onMessage(handler);
67
+ if (typeof unsubscribe === 'function') {
68
+ return unsubscribe;
69
+ }
70
+ // Fallback: allow transports that do not return an unsubscribe function.
71
+ return () => {};
72
+ }
73
+
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Type declarations for storion.
3
+ * Database instance methods are async and return Promises.
4
+ */
5
+
6
+ export type StorageType = 'localStorage' | 'sessionStorage' | 'indexedDB';
7
+
8
+ export interface CreateDatabaseOptions {
9
+ name: string;
10
+ storage: StorageType;
11
+ /** Optional config object to create DB and tables from */
12
+ config?: DBConfig;
13
+ /** Optional storage key (default: __LS_DB__) */
14
+ storageKey?: string;
15
+ }
16
+
17
+ export interface DBConfig {
18
+ databases?: Record<string, { tables?: Record<string, TableDef> }>;
19
+ tables?: Record<string, TableDef>;
20
+ }
21
+
22
+ export interface TableDef {
23
+ columns: Array<string | { name: string; type: 'int' | 'float' | 'boolean' | 'string' | 'json'; references?: { table: string; column: string } }>;
24
+ }
25
+
26
+ export interface QueryWhere {
27
+ field?: string;
28
+ op?: string;
29
+ value?: unknown;
30
+ and?: QueryWhere[];
31
+ or?: QueryWhere[];
32
+ }
33
+
34
+ export interface Query {
35
+ where?: QueryWhere | null;
36
+ orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>;
37
+ limit?: number;
38
+ offset?: number;
39
+ }
40
+
41
+ export interface QueryResult {
42
+ rows: Record<string, unknown>[];
43
+ totalCount: number;
44
+ }
45
+
46
+ export interface FetchOptions {
47
+ filter?: Record<string, unknown>;
48
+ sortBy?: string;
49
+ sortOrder?: 'asc' | 'desc';
50
+ limit?: number;
51
+ }
52
+
53
+ /** Change event emitted after insert, update, delete, createTable, or deleteTable. */
54
+ export interface StorionChangeEvent {
55
+ type: 'insert' | 'update' | 'delete' | 'tableCreated' | 'tableDeleted';
56
+ dbName: string;
57
+ tableName: string;
58
+ row?: Record<string, unknown>;
59
+ rowId?: number | string;
60
+ previousRow?: Record<string, unknown>;
61
+ }
62
+
63
+ /** Generic transport interface for receiving change events from another context. */
64
+ export interface ChangeTransport {
65
+ /**
66
+ * Register a message handler. The handler will be called with messages that
67
+ * should represent StorionChangeEvent-like objects. Returns an optional
68
+ * function that can be called to unsubscribe.
69
+ */
70
+ onMessage(handler: (message: unknown) => void): (() => void) | void;
71
+ }
72
+
73
+ /** Optional broadcaster for cross-context sync (e.g. extension ↔ webapp). */
74
+ export interface ChangeBroadcaster {
75
+ broadcastChange(event: StorionChangeEvent): void | Promise<void>;
76
+ }
77
+
78
+ export function createDatabase(options: CreateDatabaseOptions): Promise<Database>;
79
+
80
+ export function loadConfigFromUrl(url: string): Promise<DBConfig>;
81
+
82
+ export function loadConfigFromFile(file: File): Promise<DBConfig>;
83
+
84
+ export function getStorageAdapter(type: StorageType, storageKey?: string): StorageAdapter;
85
+
86
+ export function executeQuery(rows: object[], columns: unknown[], query: Query | null): QueryResult;
87
+
88
+ export function validateQuery(query: Query | null, columns: unknown[]): { valid: boolean; error?: string };
89
+
90
+ export const QUERY_OPERATORS: string[];
91
+
92
+ export function parseConfig(config: DBConfig, dbName?: string): { databases: Record<string, unknown> };
93
+
94
+ export function normalizeColumn(col: string | { name: string; type?: string }): { name: string; type: string } | null;
95
+
96
+ export function getColumnNames(columns: unknown[]): string[];
97
+
98
+ export function getColumnType(columns: unknown[], colName: string): string;
99
+
100
+ export function coerceValue(value: unknown, type: string): unknown;
101
+
102
+ export interface StorageAdapter {
103
+ getItem(): string | null | Promise<string | null>;
104
+ setItem(key: null, value: string): void | boolean | Promise<void | boolean>;
105
+ removeItem(): void | boolean | Promise<void | boolean>;
106
+ getAllKeys(): string[] | Promise<string[]>;
107
+ isAsync?: boolean;
108
+ }
109
+
110
+ export interface Database {
111
+ readonly name: string;
112
+ createTable(tableName: string, columns: Array<string | { name: string; type: string }>): Promise<boolean>;
113
+ listTables(): Promise<string[]>;
114
+ getTable(tableName: string): Promise<{ columns: unknown[]; rows: Record<string, unknown>[] }>;
115
+ insert(tableName: string, row: Record<string, unknown>): Promise<Record<string, unknown>>;
116
+ fetch(tableName: string, options?: FetchOptions): Promise<Record<string, unknown>[]>;
117
+ query(tableName: string, query: Query): Promise<QueryResult>;
118
+ update(tableName: string, id: number | string, newData: Record<string, unknown>): Promise<Record<string, unknown>>;
119
+ delete(tableName: string, id: number | string): Promise<boolean>;
120
+ deleteTable(tableName: string): Promise<boolean>;
121
+ exportConfig(): Promise<DBConfig>;
122
+ /** Subscribe to all changes in this database. Returns unsubscribe function. */
123
+ subscribe(callback: (event: StorionChangeEvent) => void): () => void;
124
+ /** Subscribe to changes for one table. Returns unsubscribe function. */
125
+ subscribe(tableName: string, callback: (event: StorionChangeEvent) => void): () => void;
126
+ /** Subscribe to changes for one row. Returns unsubscribe function. */
127
+ subscribe(tableName: string, rowId: number | string, callback: (event: StorionChangeEvent) => void): () => void;
128
+ /** Remove subscription by id (prefer using the function returned from subscribe). */
129
+ unsubscribe(id: number): void;
130
+ /** Set optional broadcaster for cross-context sync (Phase 2). */
131
+ setChangeBroadcaster(broadcaster: ChangeBroadcaster | null): void;
132
+ }
133
+
134
+ /**
135
+ * Create a listener for change events coming from another context (e.g. from
136
+ * a Chrome extension or another window) via a user-provided transport.
137
+ * Returns a function to unsubscribe.
138
+ */
139
+ export function createChangeListener(
140
+ transport: ChangeTransport,
141
+ onChange: (event: StorionChangeEvent) => void
142
+ ): () => void;