@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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/Database.js +471 -0
- package/dist/changeListener.js +73 -0
- package/dist/index.d.ts +142 -0
- package/dist/index.js +26 -0
- package/dist/queryEngine.js +299 -0
- package/dist/schema.js +121 -0
- package/dist/storage/adapters.js +197 -0
- package/docs/API.md +216 -0
- package/docs/CONFIG_FORMAT.md +117 -0
- package/docs/QUERY_LANGUAGE.md +73 -0
- package/package.json +54 -0
- package/types/index.d.ts +142 -0
package/dist/Database.js
ADDED
|
@@ -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
|
+
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|