basaltdb 0.1.0 → 0.2.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/ORM.md ADDED
@@ -0,0 +1,87 @@
1
+ # `basaltdb` models — capabilities & gaps
2
+
3
+ `basaltdb` ships a **lightweight ORM** (`db.model(name, schema)` → `Model`). This
4
+ doc is honest about what it does, and how it compares to the connectors/ORMs
5
+ people know: **mongoose** (MongoDB), **mysql2 + Sequelize/Prisma** (MySQL).
6
+
7
+ Guiding principle: the model layer only emits SQL the Basalt engine actually
8
+ supports. We'd rather throw a clear error (or omit a feature) than generate SQL
9
+ that fails at runtime. Everything marked 🔒 is blocked on an **engine** feature,
10
+ not the client (see the [roadmap](https://github.com/basalt-db/basalt/blob/main/ROADMAP.md)).
11
+
12
+ ---
13
+
14
+ ## What the model layer does today ✅
15
+
16
+ - **Define a model** with fields as SQL types or friendly aliases
17
+ (`string`→VARCHAR, `int`→INT, `bool`→BOOLEAN, `date`/`datetime`→DATE/TIMESTAMP,
18
+ `decimal`→DECIMAL, …), a `primaryKey`, and per-field `index`.
19
+ - **`sync()`** — create the table (and secondary indexes) if it doesn't exist;
20
+ `sync({force:true})` drops and recreates.
21
+ - **CRUD**: `insert` (one or many), `find` / `findOne` / `findByPk`, `count`,
22
+ `update`, `delete`.
23
+ - **Where-spec** object → SQL: equality, arrays → `IN` (expanded to an `OR`
24
+ chain the engine supports), and operators `= != <> > >= < <= between` (plus
25
+ `eq/ne/gt/gte/lt/lte/in` aliases). Multiple keys AND-ed.
26
+ - **`find` options**: `select`, `orderBy`, `limit`.
27
+ - **`toCreateSQL()`** to inspect the generated DDL.
28
+ - Values are escaped; identifiers never come from user input.
29
+ - Same API embedded (WASM) or over HTTP.
30
+
31
+ ## Feature comparison
32
+
33
+ | Capability | mongoose (Mongo) | Sequelize/Prisma (MySQL) | basaltdb Model |
34
+ |---|:--:|:--:|:--:|
35
+ | Define schema / model | ✅ | ✅ | ✅ |
36
+ | Create table/collection from model | ✅ (implicit) | ✅ `sync`/migrate | ✅ `sync()` |
37
+ | Insert one / many | ✅ | ✅ | ✅ |
38
+ | Find with filters | ✅ rich | ✅ rich | ✅ eq / IN / range / between |
39
+ | Update / delete by filter | ✅ | ✅ | ✅ |
40
+ | Count | ✅ | ✅ | ✅ |
41
+ | Order / limit | ✅ | ✅ | ✅ |
42
+ | **Pagination (offset/cursor)** | ✅ | ✅ | ❌ 🔒 no `OFFSET` in engine |
43
+ | **`LIKE` / text search** | ✅ | ✅ | ❌ 🔒 engine has no `LIKE` (throws a clear error) |
44
+ | **Aggregations (group/having)** | ✅ | ✅ | 🟡 via raw `db.query`, not the model API yet |
45
+ | **Relations / joins / populate / include** | ✅ | ✅ | ❌ use raw `JOIN` via `db.query` |
46
+ | **Validation / defaults / hooks** | ✅ | ✅ | ❌ not yet (client-side, could add) |
47
+ | **Migrations (alter schema)** | n/a | ✅ | ❌ 🔒 no `ALTER TABLE` |
48
+ | **Transactions** | ✅ | ✅ | ❌ 🔒 engine has no transactions |
49
+ | **Prepared statements / bind params** | ✅ | ✅ | ❌ 🔒 engine re-parses each call |
50
+ | **Unique / FK / check constraints** | 🟡 | ✅ | ❌ 🔒 only PRIMARY KEY today |
51
+ | **Upsert (`ON CONFLICT`/`save`)** | ✅ | ✅ | ❌ 🔒 no upsert in engine |
52
+ | **Auto-increment / default id** | ✅ | ✅ | ❌ supply the PK yourself |
53
+ | TypeScript types | 🟡/✅ | ✅ | ✅ (`.d.ts`; not schema-inferred generics yet) |
54
+
55
+ ## Gaps we *could* close in the client (no engine change needed)
56
+
57
+ These are pure-client and are the natural next PRs to `basalt-js`:
58
+
59
+ 1. **Model-level aggregations** — `User.groupBy(['tier'], { count:true, sum:'amount' })`
60
+ emitting `GROUP BY` (the engine supports it).
61
+ 2. **Client-side pagination** — `find(..., { limit, page })` by fetching and
62
+ slicing, until the engine gains `OFFSET`.
63
+ 3. **Validation / defaults / lifecycle hooks** (`beforeInsert`, `afterFind`).
64
+ 4. **`raw()` join helper** — a typed wrapper over `db.query` for `JOIN`s so
65
+ relations feel first-class even without `populate`.
66
+ 5. **Schema-inferred TS generics** — infer row types from the model schema so
67
+ `find()` returns a typed object, like Prisma.
68
+ 6. **`upsert()` emulation** — try-insert-then-update (non-atomic; documented).
69
+ 7. **`describe()` / diff** — compare a model to the live schema and warn on drift.
70
+
71
+ ## Gaps that need the engine first 🔒
72
+
73
+ Tracked in the [roadmap](https://github.com/basalt-db/basalt/blob/main/ROADMAP.md):
74
+ `LIKE`/text search & `OFFSET` (Tier 2.1), **transactions** (Tier 1.3),
75
+ **`ALTER TABLE`/migrations** and **constraints/FKs/upsert** (Tier 2.1), and
76
+ **prepared statements** (Tier 2.1). The highest-leverage single item is the
77
+ **PostgreSQL wire protocol** (Tier 4): with it, mature ORMs (Prisma, Sequelize,
78
+ TypeORM, Drizzle) could target Basalt directly and this bespoke layer becomes
79
+ optional.
80
+
81
+ ## When to use what
82
+
83
+ - **Model API** — everyday CRUD on a known schema. Cleanest for app code.
84
+ - **Query builder** (`db.table(...)`) — ad-hoc selects with fluent chaining.
85
+ - **Raw `db.query(sql)`** — anything the above don't cover yet (JOINs, GROUP BY,
86
+ window-less analytics). Always available; the model/builder are conveniences on
87
+ top of it.
package/README.md CHANGED
@@ -52,6 +52,16 @@ await db.schema(); // tables + columns
52
52
 
53
53
  In Node < 18 (no global `fetch`) pass one: `new BasaltClient(url, { fetch })`.
54
54
 
55
+ > **Need a server?** The client/server mode talks to a running Basalt server.
56
+ > Set up and run the database engine from the main repo —
57
+ > **[basalt-db/basalt → Quickstart](https://github.com/basalt-db/basalt#quickstart)**:
58
+ > ```bash
59
+ > git clone https://github.com/basalt-db/basalt.git && cd basalt
60
+ > make basalt-server
61
+ > ./basalt-server demo_root 8090 # HTTP API + Workbench on http://127.0.0.1:8090
62
+ > ```
63
+ > (The **embedded** mode above needs no server — the engine runs in-process.)
64
+
55
65
  ## Query builder (ORM-lite)
56
66
 
57
67
  A small fluent builder that generates SQL. It works the same on both `Basalt`
@@ -69,6 +79,46 @@ db.table('users').where('id', 6).delete();
69
79
  Values are escaped (`lit()`); **identifiers are never taken from user input**.
70
80
  Call `.toSQL()` on any builder to see the generated statement.
71
81
 
82
+ ## Models (lightweight ORM)
83
+
84
+ Define a table as a model and do typed CRUD without hand-writing SQL. All model
85
+ methods return Promises and work the same embedded or over HTTP.
86
+
87
+ ```js
88
+ import { Basalt } from 'basaltdb';
89
+ const db = await Basalt.create();
90
+
91
+ const User = db.model('users', {
92
+ id: { type: 'BIGINT', primaryKey: true },
93
+ name: 'string', // string → VARCHAR
94
+ tier: { type: 'int', index: true }, // creates a secondary index on sync()
95
+ active: 'boolean',
96
+ joined: 'date',
97
+ });
98
+
99
+ await User.sync(); // CREATE TABLE (+ index) if absent
100
+ await User.insert([{ id: 1, name: 'alice', tier: 1, active: true, joined: '2024-01-02' },
101
+ { id: 2, name: 'bob', tier: 2, active: false }]);
102
+
103
+ await User.find({ tier: 2 }, { orderBy: ['id', 'DESC'], limit: 10 });
104
+ await User.find({ tier: [1, 2] }); // array → IN (expanded to OR)
105
+ await User.find({ id: { '>=': 1, '<': 100 } }); // operators
106
+ await User.findByPk(1);
107
+ await User.count({ active: true });
108
+ await User.update({ tier: 3 }, { id: 2 });
109
+ await User.delete({ id: 2 });
110
+ User.toCreateSQL(); // inspect the DDL
111
+ ```
112
+
113
+ **Where-spec:** `{ col: value }` (equality), `{ col: [a, b] }` (IN), or
114
+ `{ col: { op: value } }` where `op` ∈ `= != <> > >= < <= eq ne gt gte lt lte in between`.
115
+ Multiple keys are AND-ed. Types accept SQL names (`BIGINT`, `VARCHAR`, `DECIMAL`,
116
+ `TIMESTAMP`, …) or friendly aliases (`string`, `int`, `bool`, `date`, `datetime`).
117
+
118
+ See **[ORM.md](ORM.md)** for what the model layer does and does **not** do
119
+ compared to mongoose / Sequelize / Prisma, and why (it's honest about the gaps
120
+ that come from the young engine).
121
+
72
122
  ## What Basalt supports (and doesn't, yet)
73
123
 
74
124
  This is a young, learning-oriented engine — the builder deliberately stays close
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "basaltdb",
3
- "version": "0.1.0",
4
- "description": "Official JavaScript/TypeScript client for Basalt — a from-scratch HTAP database engine. Run it embedded in-process via WebAssembly (Node & browser), or talk to a Basalt server over HTTP.",
3
+ "version": "0.2.0",
4
+ "description": "Official JavaScript/TypeScript client & lightweight ORM for Basalt — a from-scratch HTAP database engine. Run it embedded in-process via WebAssembly (Node & browser), or talk to a Basalt server over HTTP. Define models and do typed CRUD.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -16,23 +16,40 @@
16
16
  "src/index.js",
17
17
  "src/index.d.ts",
18
18
  "src/engine.js",
19
- "README.md"
19
+ "README.md",
20
+ "ORM.md"
20
21
  ],
21
22
  "scripts": {
22
- "test": "node test/smoke.mjs",
23
+ "test": "node test/smoke.mjs && node test/orm.mjs",
23
24
  "prepublishOnly": "npm test"
24
25
  },
25
26
  "keywords": [
26
- "database", "sql", "htap", "olap", "oltp", "columnar", "wasm",
27
- "webassembly", "embedded-database", "basalt", "in-memory", "analytics"
27
+ "database",
28
+ "orm",
29
+ "models",
30
+ "sql",
31
+ "htap",
32
+ "olap",
33
+ "oltp",
34
+ "columnar",
35
+ "wasm",
36
+ "webassembly",
37
+ "embedded-database",
38
+ "basalt",
39
+ "in-memory",
40
+ "analytics"
28
41
  ],
29
42
  "homepage": "https://github.com/basalt-db/basalt-js",
30
43
  "repository": {
31
44
  "type": "git",
32
45
  "url": "git+https://github.com/basalt-db/basalt-js.git"
33
46
  },
34
- "bugs": { "url": "https://github.com/basalt-db/basalt-js/issues" },
47
+ "bugs": {
48
+ "url": "https://github.com/basalt-db/basalt-js/issues"
49
+ },
35
50
  "license": "MIT",
36
- "engines": { "node": ">=18" },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
37
54
  "sideEffects": false
38
55
  }
package/src/index.d.ts CHANGED
@@ -36,6 +36,36 @@ export class QueryBuilder {
36
36
  delete(): Promise<Result> | Result;
37
37
  }
38
38
 
39
+ export type FieldType =
40
+ | 'BIGINT' | 'INT' | 'SMALLINT' | 'DOUBLE' | 'VARCHAR' | 'CHAR' | 'TEXT'
41
+ | 'BOOLEAN' | 'DATE' | 'TIMESTAMP' | 'DECIMAL'
42
+ | 'string' | 'number' | 'int' | 'integer' | 'bigint' | 'float' | 'double'
43
+ | 'bool' | 'boolean' | 'date' | 'datetime' | 'timestamp' | 'decimal' | (string & {});
44
+ export interface FieldDef { type: FieldType; primaryKey?: boolean; pk?: boolean; index?: boolean; }
45
+ export type ModelSchema = Record<string, FieldType | FieldDef>;
46
+
47
+ /** Operators usable in a where-spec: { age: { '>=': 18 }, id: { in: [1,2] } }. */
48
+ export type WhereSpec = Record<string,
49
+ Value | Value[] | Partial<Record<'='|'!='|'<>'|'>'|'>='|'<'|'<='|'eq'|'ne'|'gt'|'gte'|'lt'|'lte'|'in'|'between'|'like', any>>>;
50
+ export interface FindOptions { select?: string[]; orderBy?: string | [string, 'ASC'|'DESC'|'asc'|'desc']; limit?: number; }
51
+
52
+ /** A table mapped as a model — typed CRUD without hand-writing SQL. */
53
+ export class Model {
54
+ name: string;
55
+ pk: string | null;
56
+ toCreateSQL(): string;
57
+ sync(opts?: { force?: boolean }): Promise<Model>;
58
+ insert(rows: Record<string, Value> | Record<string, Value>[]): Promise<Result>;
59
+ find(where?: WhereSpec, opts?: FindOptions): Promise<Record<string, any>[]>;
60
+ findOne(where?: WhereSpec, opts?: FindOptions): Promise<Record<string, any> | null>;
61
+ findByPk(value: Value): Promise<Record<string, any> | null>;
62
+ count(where?: WhereSpec): Promise<number>;
63
+ update(patch: Record<string, Value>, where?: WhereSpec): Promise<Result>;
64
+ delete(where?: WhereSpec): Promise<Result>;
65
+ }
66
+ export function defineModel(runner: Basalt | BasaltClient, name: string, schema: ModelSchema): Model;
67
+ export function buildWhere(where?: WhereSpec): string;
68
+
39
69
  /** Embedded, in-process (WebAssembly) database. */
40
70
  export class Basalt {
41
71
  static create(): Promise<Basalt>;
@@ -43,6 +73,7 @@ export class Basalt {
43
73
  query(sql: string): Record<string, any>[];
44
74
  schema(): TableInfo[];
45
75
  table(name: string): QueryBuilder;
76
+ model(name: string, schema: ModelSchema): Model;
46
77
  }
47
78
 
48
79
  export interface BasaltClientOptions {
@@ -62,6 +93,7 @@ export class BasaltClient {
62
93
  dropDatabase(name: string): Promise<any>;
63
94
  use(db: string): this;
64
95
  table(name: string): QueryBuilder;
96
+ model(name: string, schema: ModelSchema): Model;
65
97
  }
66
98
 
67
99
  export function lit(v: Value): string;
package/src/index.js CHANGED
@@ -99,6 +99,109 @@ class QueryBuilder {
99
99
  }
100
100
  }
101
101
 
102
+ // ---- models / lightweight ORM --------------------------------------------
103
+ // Define a table as a model, then do typed CRUD without hand-writing SQL. Works
104
+ // on both the embedded engine and the HTTP client (every method returns a
105
+ // Promise). See ORM.md for what it does and does not do vs mongoose/Sequelize.
106
+ const TYPE_ALIASES = {
107
+ string:'VARCHAR', text:'VARCHAR', varchar:'VARCHAR', char:'CHAR',
108
+ int:'INT', integer:'INT', smallint:'SMALLINT', number:'DOUBLE',
109
+ bigint:'BIGINT', long:'BIGINT', float:'DOUBLE', double:'DOUBLE', real:'DOUBLE',
110
+ bool:'BOOLEAN', boolean:'BOOLEAN', date:'DATE', datetime:'TIMESTAMP',
111
+ timestamp:'TIMESTAMP', decimal:'DECIMAL', numeric:'DECIMAL',
112
+ };
113
+ function normField(name, def){
114
+ if (typeof def === 'string') def = { type: def };
115
+ const raw = String(def.type || 'VARCHAR');
116
+ const type = TYPE_ALIASES[raw.toLowerCase()] || raw.toUpperCase();
117
+ return { name, type, primaryKey: !!(def.primaryKey || def.pk), index: !!def.index };
118
+ }
119
+ // Build a WHERE clause from a spec object:
120
+ // { id: 1 } → WHERE id = 1
121
+ // { tier: [1,2] } → WHERE tier IN (1, 2)
122
+ // { age: { '>=': 18, '<': 65 } } → WHERE age >= 18 AND age < 65
123
+ // { name: { like: 'a%' } } / { ts: { between: [lo, hi] } } / { id: { in:[…] } }
124
+ const OPS = { eq:'=', ne:'!=', gt:'>', gte:'>=', lt:'<', lte:'<=',
125
+ '=':'=', '!=':'!=', '<>':'<>', '>':'>', '>=':'>=', '<':'<', '<=':'<=' };
126
+ // IN isn't in the engine's grammar yet, so we expand it to an OR-chain the
127
+ // engine does support: col IN (a,b) → (col = a OR col = b).
128
+ function inClause(col, list){
129
+ if (!Array.isArray(list) || !list.length) throw new BasaltError(`empty IN list for "${col}"`);
130
+ return '(' + list.map(x => `${col} = ${lit(x)}`).join(' OR ') + ')';
131
+ }
132
+ export function buildWhere(where){
133
+ if (!where) return '';
134
+ const keys = Object.keys(where); if (!keys.length) return '';
135
+ const parts = keys.map(col => {
136
+ const v = where[col];
137
+ if (Array.isArray(v)) return inClause(col, v);
138
+ if (v !== null && typeof v === 'object' && !(v instanceof Date)) {
139
+ return Object.entries(v).map(([op, val]) => {
140
+ const o = op.toLowerCase();
141
+ if (o === 'in') return inClause(col, val);
142
+ if (o === 'between') return `${col} BETWEEN ${lit(val[0])} AND ${lit(val[1])}`;
143
+ if (o === 'like') throw new BasaltError('LIKE is not supported by the Basalt engine yet — see ROADMAP.md');
144
+ const sql = OPS[o]; if (!sql) throw new BasaltError('unknown operator: ' + op);
145
+ return `${col} ${sql} ${lit(val)}`;
146
+ }).join(' AND ');
147
+ }
148
+ return `${col} = ${lit(v)}`;
149
+ });
150
+ return ' WHERE ' + parts.join(' AND ');
151
+ }
152
+
153
+ export class Model {
154
+ constructor(runner, name, schema){
155
+ this.r = runner; this.name = name; this.schema = schema;
156
+ this.fields = Object.entries(schema).map(([k, v]) => normField(k, v));
157
+ this.pk = (this.fields.find(f => f.primaryKey) || {}).name || null;
158
+ }
159
+ /** The `CREATE TABLE` statement this model maps to. */
160
+ toCreateSQL(){
161
+ const cols = this.fields.map(f => ' ' + f.name + ' ' + f.type + (f.primaryKey ? ' PRIMARY KEY' : ''));
162
+ return `CREATE TABLE ${this.name} (\n${cols.join(',\n')}\n)`;
163
+ }
164
+ indexDDL(){ return this.fields.filter(f => f.index && !f.primaryKey)
165
+ .map(f => `CREATE INDEX ${this.name}_${f.name} ON ${this.name} (${f.name})`); }
166
+ _run(sql){ return Promise.resolve(this.r._run(sql)); }
167
+ /** Create the table (+ indexes) if it doesn't exist. `{force:true}` drops first. */
168
+ async sync(opts = {}){
169
+ const tables = await Promise.resolve(this.r.schema());
170
+ const exists = (tables || []).some(t => t.name === this.name);
171
+ if (exists && opts.force) { await this._run('DROP TABLE IF EXISTS ' + this.name); }
172
+ if (!exists || opts.force) {
173
+ await this._run(this.toCreateSQL());
174
+ for (const d of this.indexDDL()) await this._run(d);
175
+ }
176
+ return this;
177
+ }
178
+ /** Insert one object or an array of objects (values follow the model's column order). */
179
+ async insert(rows){
180
+ const list = Array.isArray(rows) ? rows : [rows];
181
+ if (!list.length) return { message: 'INSERT 0' };
182
+ const cols = this.fields.map(f => f.name);
183
+ const values = list.map(o => `(${cols.map(c => lit(o[c] === undefined ? null : o[c])).join(', ')})`).join(', ');
184
+ return this._run(`INSERT INTO ${this.name} (${cols.join(', ')}) VALUES ${values}`);
185
+ }
186
+ /** Find rows. opts: { select:[...], orderBy:'col'|['col','DESC'], limit:n }. */
187
+ async find(where, opts = {}){
188
+ let sql = 'SELECT ' + (opts.select ? opts.select.join(', ') : '*') + ' FROM ' + this.name + buildWhere(where);
189
+ if (opts.orderBy){ const [c, d] = Array.isArray(opts.orderBy) ? opts.orderBy : [opts.orderBy, 'ASC'];
190
+ sql += ` ORDER BY ${c} ${String(d).toUpperCase()}`; }
191
+ if (opts.limit != null) sql += ' LIMIT ' + (opts.limit | 0);
192
+ return (await this._run(sql)).toObjects();
193
+ }
194
+ async findOne(where, opts = {}){ return (await this.find(where, { ...opts, limit: 1 }))[0] ?? null; }
195
+ async findByPk(v){ if (!this.pk) throw new BasaltError(`model ${this.name} has no primary key`);
196
+ return this.findOne({ [this.pk]: v }); }
197
+ async count(where){ return Number((await this._run('SELECT COUNT(*) AS n FROM ' + this.name + buildWhere(where))).toObjects()[0].n); }
198
+ async update(patch, where){ const set = Object.keys(patch).map(c => `${c} = ${lit(patch[c])}`).join(', ');
199
+ return this._run(`UPDATE ${this.name} SET ${set}${buildWhere(where)}`); }
200
+ async delete(where){ return this._run(`DELETE FROM ${this.name}${buildWhere(where)}`); }
201
+ }
202
+ /** Bind a model to any runner (Basalt or BasaltClient). */
203
+ export function defineModel(runner, name, schema){ return new Model(runner, name, schema); }
204
+
102
205
  // ---- embedded engine (WASM, in-process) ----------------------------------
103
206
  export class Basalt {
104
207
  constructor(mod) {
@@ -121,6 +224,8 @@ export class Basalt {
121
224
  schema() { return JSON.parse(this._schemaFn()).tables; }
122
225
  /** Start a query builder on a table. */
123
226
  table(name) { return new QueryBuilder(this, name); }
227
+ /** Define a model (see Model) bound to this database. */
228
+ model(name, schema) { return new Model(this, name, schema); }
124
229
  }
125
230
 
126
231
  // ---- HTTP client (talks to a running `basalt` server) --------------------
@@ -155,6 +260,8 @@ export class BasaltClient {
155
260
  use(db) { this.db = db; return this; }
156
261
  /** Start a query builder on a table. */
157
262
  table(name) { return new QueryBuilder(this, name); }
263
+ /** Define a model (see Model) bound to this client. */
264
+ model(name, schema) { return new Model(this, name, schema); }
158
265
  }
159
266
 
160
267
  export default Basalt;