seedorm 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # seedorm
2
2
 
3
- Development-first ORM that lets you start with a JSON file and migrate to any SQL database by changing one line of config. No rewrites.
3
+ Development-first ORM that lets you start with a JSON file and migrate to any SQL database by changing one line of config. No rewrites. No drama.
4
4
 
5
5
  ## Why
6
6
 
7
- Every project starts the same way: you need to store data, but you don't want to set up a database just to prototype. SeedORM lets you start building immediately with a local JSON file, then switch to a real database when you're ready without changing your application code.
7
+ Every project starts the same way: you need to store data, but you don't want to set up a database just to prototype. SeedORM lets you start building immediately with a local JSON file, then switch to a real database when you're ready. Your application code doesn't change. Not one line.
8
8
 
9
9
  ## Quick start
10
10
 
@@ -15,37 +15,41 @@ npm install seedorm
15
15
  ```typescript
16
16
  import { SeedORM, FieldType } from "seedorm";
17
17
 
18
- const db = new SeedORM();
19
- await db.connect();
20
-
21
- const User = db.model({
22
- name: "User",
23
- collection: "users",
24
- schema: {
25
- name: { type: FieldType.String, required: true },
26
- email: { type: FieldType.String, unique: true },
27
- role: { type: FieldType.String, enum: ["admin", "user"], default: "user" },
28
- },
29
- });
30
- await User.init();
31
-
32
- // Create
33
- const alice = await User.create({ name: "Alice", email: "alice@example.com" });
34
-
35
- // Query with MongoDB-style operators
36
- const admins = await User.find({
37
- filter: { role: { $eq: "admin" } },
38
- sort: { name: 1 },
39
- limit: 10,
40
- });
41
-
42
- // Update
43
- await User.update(alice.id, { role: "admin" });
44
-
45
- // Delete
46
- await User.delete(alice.id);
18
+ async function main() {
19
+ const db = new SeedORM();
20
+ await db.connect();
21
+
22
+ const User = db.model({
23
+ name: "User",
24
+ collection: "users",
25
+ schema: {
26
+ name: { type: FieldType.String, required: true },
27
+ email: { type: FieldType.String, unique: true },
28
+ role: { type: FieldType.String, enum: ["admin", "user"], default: "user" },
29
+ },
30
+ });
31
+ await User.init();
32
+
33
+ // Create
34
+ const alice = await User.create({ name: "Alice", email: "alice@example.com" });
35
+
36
+ // Query with MongoDB-style operators
37
+ const admins = await User.find({
38
+ filter: { role: { $eq: "admin" } },
39
+ sort: { name: 1 },
40
+ limit: 10,
41
+ });
42
+
43
+ // Update
44
+ await User.update(alice.id, { role: "admin" });
45
+
46
+ // Delete
47
+ await User.delete(alice.id);
48
+
49
+ await db.disconnect();
50
+ }
47
51
 
48
- await db.disconnect();
52
+ main();
49
53
  ```
50
54
 
51
55
  ## Relations
@@ -87,8 +91,8 @@ const user = await User.findById("usr_abc123", {
87
91
  });
88
92
 
89
93
  // Manage many-to-many links
90
- await User.associate("roles", "usr_abc123", "rol_editor");
91
- await User.dissociate("roles", "usr_abc123", "rol_editor");
94
+ await User.associate("usr_abc123", "roles", "rol_editor");
95
+ await User.dissociate("usr_abc123", "roles", "rol_editor");
92
96
  ```
93
97
 
94
98
  **Relation types:** `hasOne`, `hasMany`, `belongsTo`, `manyToMany`
@@ -123,14 +127,14 @@ SeedORM exports string enums for type-safe definitions. Plain strings also work
123
127
 
124
128
  ## Features
125
129
 
126
- - **Zero-config start** data lives in a JSON file, no database setup needed
127
- - **Schema validation** type checking, required fields, unique constraints, min/max, enums
128
- - **Relations** `hasOne`, `hasMany`, `belongsTo`, `manyToMany` with eager loading via `include`
129
- - **Query operators** `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$exists`
130
- - **CLI tools** `seedorm init`, `seedorm start` (REST server), `seedorm studio` (visual UI)
131
- - **Migration engine** `migrate create`, `migrate up`, `migrate to` (SQL export)
132
- - **Pluggable adapters** PostgreSQL built-in, MySQL and SQLite coming soon. Drivers are lazy-loaded and optional.
133
- - **TypeScript** written in TypeScript with full type exports, dual CJS/ESM output
130
+ - **Zero-config start**: data lives in a JSON file, no database setup needed
131
+ - **Schema validation**: type checking, required fields, unique constraints, min/max, enums
132
+ - **Relations**: `hasOne`, `hasMany`, `belongsTo`, `manyToMany` with eager loading via `include`
133
+ - **Query operators**: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$exists`
134
+ - **CLI tools**: `seedorm init`, `seedorm start` (REST server), `seedorm studio` (visual UI)
135
+ - **Migration engine**: `migrate create`, `migrate up`, `migrate to` (SQL export)
136
+ - **Pluggable adapters**: PostgreSQL built-in, MySQL and SQLite coming soon. Drivers are lazy-loaded and optional.
137
+ - **TypeScript**: written in TypeScript with full type exports, dual CJS/ESM output
134
138
 
135
139
  ## CLI
136
140
 
@@ -165,7 +169,7 @@ When running `seedorm start`, the following endpoints are available:
165
169
  | `GET` | `/api/:collection` | List documents (supports `?filter=`, `?sort=`, `?limit=`, `?offset=`) |
166
170
  | `GET` | `/api/:collection/:id` | Get document by ID |
167
171
  | `POST` | `/api/:collection` | Create document |
168
- | `PATCH` | `/api/:collection/:id` | Update document |
172
+ | `PUT/PATCH` | `/api/:collection/:id` | Update document |
169
173
  | `DELETE` | `/api/:collection/:id` | Delete document |
170
174
 
171
175
  ## Requirements
@@ -174,6 +178,14 @@ When running `seedorm start`, the following endpoints are available:
174
178
  - `pg` (optional, only needed for PostgreSQL adapter)
175
179
  - `mysql2` (optional, only needed for MySQL adapter)
176
180
 
181
+ ## Links
182
+
183
+ - [Documentation](https://seedorm.io/docs)
184
+ - [Getting Started](https://seedorm.io/docs/getting-started)
185
+ - [API Reference](https://seedorm.io/docs/api)
186
+ - [CLI Reference](https://seedorm.io/docs/cli)
187
+ - [Tutorial](https://seedorm.io/docs/tutorial)
188
+
177
189
  ## License
178
190
 
179
- Apache 2.0 — see [LICENSE](./LICENSE)
191
+ Apache 2.0
@@ -4369,6 +4369,11 @@ var Model = class {
4369
4369
  }
4370
4370
  async init() {
4371
4371
  await this.adapter.createCollection(this.collection, this.schema);
4372
+ for (const rel of Object.values(this.relations)) {
4373
+ if (rel.type === "manyToMany" /* ManyToMany */ && rel.joinCollection) {
4374
+ await this.adapter.createCollection(rel.joinCollection, {});
4375
+ }
4376
+ }
4372
4377
  }
4373
4378
  generateId() {
4374
4379
  return `${this.prefix}_${nanoid(12)}`;
@@ -4645,68 +4650,82 @@ var fs3 = __toESM(require("fs"), 1);
4645
4650
  var path3 = __toESM(require("path"), 1);
4646
4651
  var import_write_file_atomic = __toESM(require_lib(), 1);
4647
4652
  var FileEngine = class {
4648
- filePath;
4649
- data = {};
4653
+ dirPath;
4654
+ data = /* @__PURE__ */ new Map();
4655
+ dirty = /* @__PURE__ */ new Set();
4650
4656
  writeQueue = Promise.resolve();
4651
- dirty = false;
4652
- constructor(filePath) {
4653
- this.filePath = path3.resolve(filePath);
4657
+ constructor(dirPath) {
4658
+ this.dirPath = path3.resolve(dirPath);
4654
4659
  }
4655
4660
  async load() {
4656
- try {
4657
- const raw = fs3.readFileSync(this.filePath, "utf-8");
4658
- this.data = JSON.parse(raw);
4659
- } catch (err) {
4660
- if (err.code === "ENOENT") {
4661
- this.data = {};
4662
- await this.flush();
4663
- } else {
4664
- throw err;
4661
+ if (!fs3.existsSync(this.dirPath)) {
4662
+ fs3.mkdirSync(this.dirPath, { recursive: true });
4663
+ }
4664
+ const legacyPath = path3.join(this.dirPath, "seedorm.json");
4665
+ if (fs3.existsSync(legacyPath)) {
4666
+ const raw = fs3.readFileSync(legacyPath, "utf-8");
4667
+ const legacy = JSON.parse(raw);
4668
+ for (const [collection, docs] of Object.entries(legacy)) {
4669
+ this.data.set(collection, docs);
4670
+ this.dirty.add(collection);
4665
4671
  }
4672
+ await this.flush();
4673
+ fs3.unlinkSync(legacyPath);
4674
+ return;
4675
+ }
4676
+ const files = fs3.readdirSync(this.dirPath).filter((f) => f.endsWith(".json"));
4677
+ for (const file of files) {
4678
+ const collection = file.slice(0, -5);
4679
+ const raw = fs3.readFileSync(path3.join(this.dirPath, file), "utf-8");
4680
+ this.data.set(collection, JSON.parse(raw));
4666
4681
  }
4667
- }
4668
- getData() {
4669
- return this.data;
4670
4682
  }
4671
4683
  getCollection(name) {
4672
- if (!this.data[name]) {
4673
- this.data[name] = [];
4684
+ if (!this.data.has(name)) {
4685
+ this.data.set(name, []);
4674
4686
  }
4675
- return this.data[name];
4687
+ return this.data.get(name);
4676
4688
  }
4677
4689
  hasCollection(name) {
4678
- return name in this.data;
4690
+ return this.data.has(name);
4679
4691
  }
4680
4692
  createCollection(name) {
4681
- if (!this.data[name]) {
4682
- this.data[name] = [];
4693
+ if (!this.data.has(name)) {
4694
+ this.data.set(name, []);
4695
+ this.dirty.add(name);
4683
4696
  }
4684
4697
  }
4685
4698
  dropCollection(name) {
4686
- delete this.data[name];
4699
+ this.data.delete(name);
4700
+ this.dirty.delete(name);
4701
+ const filePath = path3.join(this.dirPath, `${name}.json`);
4702
+ if (fs3.existsSync(filePath)) {
4703
+ fs3.unlinkSync(filePath);
4704
+ }
4687
4705
  }
4688
4706
  listCollections() {
4689
- return Object.keys(this.data);
4707
+ return Array.from(this.data.keys());
4690
4708
  }
4691
- markDirty() {
4692
- this.dirty = true;
4709
+ markDirty(collection) {
4710
+ this.dirty.add(collection);
4693
4711
  }
4694
4712
  async flush() {
4713
+ const toWrite = new Set(this.dirty);
4714
+ this.dirty.clear();
4695
4715
  this.writeQueue = this.writeQueue.then(async () => {
4696
- const dir = path3.dirname(this.filePath);
4697
- if (!fs3.existsSync(dir)) {
4698
- fs3.mkdirSync(dir, { recursive: true });
4716
+ for (const collection of toWrite) {
4717
+ const docs = this.data.get(collection);
4718
+ if (docs === void 0) continue;
4719
+ await (0, import_write_file_atomic.default)(
4720
+ path3.join(this.dirPath, `${collection}.json`),
4721
+ JSON.stringify(docs)
4722
+ );
4699
4723
  }
4700
- await (0, import_write_file_atomic.default)(
4701
- this.filePath,
4702
- JSON.stringify(this.data, null, 2) + "\n"
4703
- );
4704
- this.dirty = false;
4705
4724
  });
4706
4725
  return this.writeQueue;
4707
4726
  }
4708
4727
  async flushIfDirty() {
4709
- if (this.dirty) {
4728
+ if (this.dirty.size > 0) {
4710
4729
  await this.flush();
4711
4730
  }
4712
4731
  }
@@ -4792,8 +4811,8 @@ var JsonAdapter = class {
4792
4811
  engine;
4793
4812
  indexer = new Indexer();
4794
4813
  schemas = /* @__PURE__ */ new Map();
4795
- constructor(filePath) {
4796
- this.engine = new FileEngine(filePath);
4814
+ constructor(dirPath) {
4815
+ this.engine = new FileEngine(dirPath);
4797
4816
  }
4798
4817
  async connect() {
4799
4818
  await this.engine.load();
@@ -4829,7 +4848,7 @@ var JsonAdapter = class {
4829
4848
  const docs = this.getCollectionOrThrow(collection);
4830
4849
  this.indexer.onInsert(collection, doc);
4831
4850
  docs.push(doc);
4832
- this.engine.markDirty();
4851
+ this.engine.markDirty(collection);
4833
4852
  await this.engine.flush();
4834
4853
  return doc;
4835
4854
  }
@@ -4853,7 +4872,7 @@ var JsonAdapter = class {
4853
4872
  const newDoc = { ...oldDoc, ...data, id: oldDoc.id };
4854
4873
  this.indexer.onUpdate(collection, oldDoc, newDoc);
4855
4874
  docs[index] = newDoc;
4856
- this.engine.markDirty();
4875
+ this.engine.markDirty(collection);
4857
4876
  await this.engine.flush();
4858
4877
  return newDoc;
4859
4878
  }
@@ -4863,7 +4882,7 @@ var JsonAdapter = class {
4863
4882
  if (index === -1) return false;
4864
4883
  this.indexer.onDelete(collection, docs[index]);
4865
4884
  docs.splice(index, 1);
4866
- this.engine.markDirty();
4885
+ this.engine.markDirty(collection);
4867
4886
  await this.engine.flush();
4868
4887
  return true;
4869
4888
  }
@@ -4879,7 +4898,7 @@ var JsonAdapter = class {
4879
4898
  docs.length = 0;
4880
4899
  docs.push(...remaining);
4881
4900
  if (deleted > 0) {
4882
- this.engine.markDirty();
4901
+ this.engine.markDirty(collection);
4883
4902
  await this.engine.flush();
4884
4903
  }
4885
4904
  return deleted;
@@ -4908,11 +4927,8 @@ var SeedORM = class {
4908
4927
  async createAdapter(adapterConfig) {
4909
4928
  switch (adapterConfig.adapter) {
4910
4929
  case "json" /* Json */: {
4911
- const dbPath = path4.resolve(
4912
- adapterConfig.path ?? "./data",
4913
- "seedorm.json"
4914
- );
4915
- return new JsonAdapter(dbPath);
4930
+ const dirPath = path4.resolve(adapterConfig.path ?? "./data");
4931
+ return new JsonAdapter(dirPath);
4916
4932
  }
4917
4933
  case "postgres" /* Postgres */: {
4918
4934
  const { PostgresAdapter: PostgresAdapter2 } = await Promise.resolve().then(() => (init_postgres_adapter(), postgres_adapter_exports));
@@ -5489,13 +5505,26 @@ function createApiHandler(db) {
5489
5505
  const limitParam = url.searchParams.get("limit");
5490
5506
  const offsetParam = url.searchParams.get("offset");
5491
5507
  const sortParam = url.searchParams.get("sort");
5492
- const opts = {};
5493
- if (filterParam) opts.filter = JSON.parse(filterParam);
5494
- if (limitParam) opts.limit = parseInt(limitParam, 10);
5495
- if (offsetParam) opts.offset = parseInt(offsetParam, 10);
5496
- if (sortParam) opts.sort = JSON.parse(sortParam);
5497
- const docs = await adapter.find(collection, opts);
5498
- const total = await adapter.count(collection, opts.filter);
5508
+ const searchParam = url.searchParams.get("search");
5509
+ const findOpts = {};
5510
+ if (filterParam) findOpts.filter = JSON.parse(filterParam);
5511
+ if (sortParam) findOpts.sort = JSON.parse(sortParam);
5512
+ let allDocs = await adapter.find(collection, findOpts);
5513
+ if (searchParam) {
5514
+ const q = searchParam.toLowerCase();
5515
+ allDocs = allDocs.filter(
5516
+ (doc) => Object.values(doc).some((v) => {
5517
+ if (v == null) return false;
5518
+ const s = typeof v === "string" ? v : JSON.stringify(v);
5519
+ return s.toLowerCase().includes(q);
5520
+ })
5521
+ );
5522
+ }
5523
+ const total = allDocs.length;
5524
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
5525
+ const limit = limitParam ? parseInt(limitParam, 10) : void 0;
5526
+ let docs = allDocs.slice(offset);
5527
+ if (limit !== void 0) docs = docs.slice(0, limit);
5499
5528
  return json2(res, 200, { data: docs, total });
5500
5529
  }
5501
5530
  case "POST": {
@@ -5650,7 +5679,7 @@ async function studioCommand(options) {
5650
5679
  // src/cli/index.ts
5651
5680
  function createCLI() {
5652
5681
  const program3 = new Command();
5653
- program3.name("seedorm").description("Development-first ORM \u2014 start with JSON, migrate to PostgreSQL/MySQL").version("0.1.0");
5682
+ program3.name("seedorm").description("Development-first ORM \u2014 start with JSON, migrate to PostgreSQL/MySQL").version("0.2.1");
5654
5683
  program3.command("init").description("Initialize a new seedorm project").option("-f, --force", "Overwrite existing config").action(initCommand);
5655
5684
  program3.command("start").description("Start the development REST API server").option("-p, --port <port>", "Port to listen on", "4100").action(startCommand);
5656
5685
  const migrate = program3.command("migrate").description("Migration commands");
@@ -1,6 +1,11 @@
1
1
  const API = '/api';
2
+ const PAGE_SIZE = 50;
2
3
  let currentCollection = null;
3
4
  let currentDoc = null;
5
+ let currentPage = 0;
6
+ let totalDocs = 0;
7
+ let searchQuery = '';
8
+ let searchTimer = null;
4
9
 
5
10
  async function api(path, opts = {}) {
6
11
  const res = await fetch(`${API}${path}`, {
@@ -25,6 +30,9 @@ async function loadCollections() {
25
30
 
26
31
  async function selectCollection(name) {
27
32
  currentCollection = name;
33
+ currentPage = 0;
34
+ searchQuery = '';
35
+ document.getElementById('search-input').value = '';
28
36
  document.getElementById('empty-state').classList.add('hidden');
29
37
  document.getElementById('collection-view').classList.remove('hidden');
30
38
  document.getElementById('collection-name').textContent = name;
@@ -34,15 +42,21 @@ async function selectCollection(name) {
34
42
 
35
43
  async function loadDocuments() {
36
44
  if (!currentCollection) return;
37
- const { data, total } = await api(`/data/${currentCollection}?limit=100`);
45
+ const offset = currentPage * PAGE_SIZE;
46
+ let url = `/data/${currentCollection}?limit=${PAGE_SIZE}&offset=${offset}`;
47
+ if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
48
+
49
+ const { data, total } = await api(url);
50
+ totalDocs = total;
38
51
  document.getElementById('doc-count').textContent = `${total} documents`;
39
52
 
40
53
  const thead = document.getElementById('table-head');
41
54
  const tbody = document.getElementById('table-body');
42
55
 
43
56
  if (!data || data.length === 0) {
44
- thead.innerHTML = '<th>No documents</th>';
57
+ thead.innerHTML = `<th>${searchQuery ? 'No matching documents' : 'No documents'}</th>`;
45
58
  tbody.innerHTML = '';
59
+ updatePagination();
46
60
  return;
47
61
  }
48
62
 
@@ -63,6 +77,23 @@ async function loadDocuments() {
63
77
  tbody.querySelectorAll('tr').forEach(tr => {
64
78
  tr.onclick = () => editDocument(data.find(d => d.id === tr.dataset.id));
65
79
  });
80
+
81
+ updatePagination();
82
+ }
83
+
84
+ function updatePagination() {
85
+ const totalPages = Math.max(1, Math.ceil(totalDocs / PAGE_SIZE));
86
+ const pag = document.getElementById('pagination');
87
+
88
+ if (totalPages <= 1) {
89
+ pag.classList.add('hidden');
90
+ return;
91
+ }
92
+
93
+ pag.classList.remove('hidden');
94
+ document.getElementById('page-info').textContent = `Page ${currentPage + 1} of ${totalPages}`;
95
+ document.getElementById('btn-prev').disabled = currentPage === 0;
96
+ document.getElementById('btn-next').disabled = currentPage >= totalPages - 1;
66
97
  }
67
98
 
68
99
  function editDocument(doc) {
@@ -122,5 +153,22 @@ document.getElementById('modal').onclick = (e) => {
122
153
  if (e.target === document.getElementById('modal')) closeModal();
123
154
  };
124
155
 
156
+ document.getElementById('btn-prev').onclick = () => {
157
+ if (currentPage > 0) { currentPage--; loadDocuments(); }
158
+ };
159
+ document.getElementById('btn-next').onclick = () => {
160
+ const totalPages = Math.ceil(totalDocs / PAGE_SIZE);
161
+ if (currentPage < totalPages - 1) { currentPage++; loadDocuments(); }
162
+ };
163
+
164
+ document.getElementById('search-input').oninput = (e) => {
165
+ clearTimeout(searchTimer);
166
+ searchTimer = setTimeout(() => {
167
+ searchQuery = e.target.value.trim();
168
+ currentPage = 0;
169
+ loadDocuments();
170
+ }, 250);
171
+ };
172
+
125
173
  // Initial load
126
174
  loadCollections();
@@ -25,12 +25,20 @@
25
25
  <div class="toolbar">
26
26
  <h2 id="collection-name"></h2>
27
27
  <span id="doc-count" class="dim"></span>
28
- <button id="btn-add" class="btn">+ Add Document</button>
28
+ <div class="toolbar-right">
29
+ <input type="text" id="search-input" placeholder="Search..." autocomplete="off">
30
+ <button id="btn-add" class="btn">+ Add Document</button>
31
+ </div>
29
32
  </div>
30
33
  <table id="docs-table">
31
34
  <thead><tr id="table-head"></tr></thead>
32
35
  <tbody id="table-body"></tbody>
33
36
  </table>
37
+ <div id="pagination" class="pagination hidden">
38
+ <button id="btn-prev" class="btn">&larr; Prev</button>
39
+ <span id="page-info" class="dim"></span>
40
+ <button id="btn-next" class="btn">Next &rarr;</button>
41
+ </div>
34
42
  </div>
35
43
  </section>
36
44
  </main>
@@ -90,6 +90,27 @@ aside li.active .count { background: rgba(255,255,255,0.2); }
90
90
 
91
91
  .toolbar h2 { font-size: 1.2rem; }
92
92
 
93
+ .toolbar-right {
94
+ margin-left: auto;
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 0.5rem;
98
+ }
99
+
100
+ #search-input {
101
+ padding: 0.4rem 0.75rem;
102
+ background: var(--surface);
103
+ border: 1px solid var(--border);
104
+ border-radius: 6px;
105
+ color: var(--text);
106
+ font-size: 0.85rem;
107
+ width: 200px;
108
+ outline: none;
109
+ }
110
+
111
+ #search-input:focus { border-color: var(--accent); }
112
+ #search-input::placeholder { color: var(--dim); }
113
+
93
114
  .btn {
94
115
  padding: 0.4rem 0.8rem;
95
116
  border: 1px solid var(--border);
@@ -135,6 +156,20 @@ td {
135
156
  tr:hover td { background: var(--surface); }
136
157
  tr { cursor: pointer; }
137
158
 
159
+ .pagination {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ gap: 1rem;
164
+ padding: 1rem 0;
165
+ }
166
+
167
+ .pagination .btn:disabled {
168
+ opacity: 0.3;
169
+ cursor: default;
170
+ border-color: var(--border);
171
+ }
172
+
138
173
  .hidden { display: none !important; }
139
174
 
140
175
  #empty-state {