Skip to content

Commit 9d4f217

Browse files
authored
Merge pull request #10 from maifeeulasad/schema-versioning
schema versioning
2 parents 8ff53b4 + 082cf92 commit 9d4f217

4 files changed

Lines changed: 301 additions & 13 deletions

File tree

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ npm i idb-ts
3939
-**Easy CRUD Operations** - Perform create, read, update, and delete seamlessly.
4040
- 🚀 **Fully Typed API** - Benefit from TypeScript’s powerful type system.
4141
- 🏎️ **Performance Optimized** - Minimal overhead with IndexedDB's native capabilities.
42+
- 🔄 **Schema Versioning** - Manage database schema evolution with automatic migration support.
4243

4344
---
4445

@@ -172,6 +173,68 @@ const firstElectronic = await db.Product.findOneByIndex('category', 'Electronics
172173

173174
---
174175

176+
## 🔄 Schema Versioning
177+
178+
idb-ts supports schema versioning to manage database evolution over time. Version your entities and let the library handle automatic migration!
179+
180+
### Basic Usage
181+
182+
```typescript
183+
@DataClass({ version: 1 })
184+
class User {
185+
@KeyPath() id!: string;
186+
@Index() email!: string;
187+
name!: string;
188+
}
189+
190+
@DataClass({ version: 2 })
191+
class Post {
192+
@KeyPath() id!: string;
193+
@Index() authorId!: string;
194+
title!: string;
195+
content!: string;
196+
}
197+
198+
@DataClass({ version: 3 })
199+
class Comment {
200+
@KeyPath() id!: string;
201+
@Index() postId!: string;
202+
@Index() authorId!: string;
203+
text!: string;
204+
}
205+
206+
// Database version will be 3 (highest entity version)
207+
const db = await Database.build("blog", [User, Post, Comment]);
208+
209+
console.log(db.getDatabaseVersion()); // 3
210+
console.log(db.getEntityVersions()); // Map with entity versions
211+
```
212+
213+
### Key Features
214+
215+
- **Automatic Version Calculation**: Database version = highest entity version
216+
- **Seamless Migration**: Only new/updated entities are processed during upgrades
217+
- **Backward Compatibility**: Entities without version default to version 1
218+
- **Index Evolution**: New indexes are automatically created during migration
219+
220+
### Version Management
221+
222+
```typescript
223+
// Check versions
224+
const dbVersion = db.getDatabaseVersion();
225+
const entityVersions = db.getEntityVersions();
226+
const userVersion = db.getEntityVersion('User');
227+
228+
// Version upgrade flow:
229+
// v1.0: User(v1) → Database v1
230+
// v1.1: User(v1), Post(v2) → Database v2
231+
// v1.2: User(v1), Post(v2), Comment(v3) → Database v3
232+
```
233+
234+
📖 **[Complete Schema Versioning Guide](./SCHEMA_VERSIONING.md)** - Detailed documentation with examples and best practices.
235+
236+
---
237+
175238
## 🔗 Useful Links
176239
- 📂 **GitHub**: [maifeeulasad/idb-ts](https://github.com/maifeeulasad/idb-ts)
177240
- 📦 **NPM**: [idb-ts](https://www.npmjs.com/package/idb-ts)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Database, KeyPath, DataClass, Index, EntityRepository } from '../index';
2+
3+
// Test entities with different versions
4+
@DataClass({ version: 1 })
5+
class UserV1 {
6+
@KeyPath()
7+
id!: string;
8+
9+
@Index()
10+
email!: string;
11+
12+
name!: string;
13+
14+
constructor(id: string, name: string, email: string) {
15+
this.id = id;
16+
this.name = name;
17+
this.email = email;
18+
}
19+
}
20+
21+
@DataClass({ version: 2 })
22+
class PostV2 {
23+
@KeyPath()
24+
id!: string;
25+
26+
@Index()
27+
authorId!: string;
28+
29+
title!: string;
30+
content!: string;
31+
32+
constructor(id: string, authorId: string, title: string, content: string) {
33+
this.id = id;
34+
this.authorId = authorId;
35+
this.title = title;
36+
this.content = content;
37+
}
38+
}
39+
40+
@DataClass({ version: 4 })
41+
class CommentV4 {
42+
@KeyPath()
43+
id!: string;
44+
45+
@Index()
46+
postId!: string;
47+
48+
@Index()
49+
userId!: string;
50+
51+
text!: string;
52+
timestamp!: Date;
53+
54+
constructor(id: string, postId: string, userId: string, text: string) {
55+
this.id = id;
56+
this.postId = postId;
57+
this.userId = userId;
58+
this.text = text;
59+
this.timestamp = new Date();
60+
}
61+
}
62+
63+
// Test with default version (should be 1)
64+
@DataClass()
65+
class TagDefault {
66+
@KeyPath()
67+
id!: string;
68+
69+
name!: string;
70+
71+
constructor(id: string, name: string) {
72+
this.id = id;
73+
this.name = name;
74+
}
75+
}
76+
77+
describe('Schema Versioning', () => {
78+
let db: any;
79+
80+
beforeAll(async () => {
81+
// Clear any existing database
82+
const deleteRequest = indexedDB.deleteDatabase('VersionTestDB');
83+
await new Promise<void>((resolve) => {
84+
deleteRequest.onsuccess = () => resolve();
85+
deleteRequest.onerror = () => resolve(); // Continue even if deletion fails
86+
});
87+
88+
db = await Database.build('VersionTestDB', [UserV1, PostV2, CommentV4, TagDefault]);
89+
});
90+
91+
it('should calculate correct database version from highest entity version', () => {
92+
expect(db.getDatabaseVersion()).toBe(4); // Highest version among entities
93+
});
94+
95+
it('should track entity versions correctly', () => {
96+
const versions = db.getEntityVersions();
97+
expect(versions.get('UserV1')).toBe(1);
98+
expect(versions.get('PostV2')).toBe(2);
99+
expect(versions.get('CommentV4')).toBe(4);
100+
expect(versions.get('TagDefault')).toBe(1); // Default version
101+
});
102+
103+
it('should get individual entity version', () => {
104+
expect(db.getEntityVersion('UserV1')).toBe(1);
105+
expect(db.getEntityVersion('PostV2')).toBe(2);
106+
expect(db.getEntityVersion('CommentV4')).toBe(4);
107+
expect(db.getEntityVersion('TagDefault')).toBe(1);
108+
expect(db.getEntityVersion('NonExistent')).toBeUndefined();
109+
});
110+
111+
it('should create and manage entities with different versions', async () => {
112+
// Test UserV1 (version 1)
113+
const user = new UserV1('u1', 'Alice', 'alice@example.com');
114+
await db.UserV1.create(user);
115+
const retrievedUser = await db.UserV1.read('u1');
116+
expect(retrievedUser).toEqual(user);
117+
118+
// Test PostV2 (version 2)
119+
const post = new PostV2('p1', 'u1', 'Hello World', 'This is my first post');
120+
await db.PostV2.create(post);
121+
const retrievedPost = await db.PostV2.read('p1');
122+
expect(retrievedPost).toEqual(post);
123+
124+
// Test CommentV4 (version 4)
125+
const comment = new CommentV4('c1', 'p1', 'u1', 'Great post!');
126+
await db.CommentV4.create(comment);
127+
const retrievedComment = await db.CommentV4.read('c1');
128+
expect(retrievedComment?.text).toBe('Great post!');
129+
130+
// Test TagDefault (default version 1)
131+
const tag = new TagDefault('t1', 'typescript');
132+
await db.TagDefault.create(tag);
133+
const retrievedTag = await db.TagDefault.read('t1');
134+
expect(retrievedTag).toEqual(tag);
135+
});
136+
137+
it('should handle indexes correctly for different versions', async () => {
138+
// Test finding by index in UserV1
139+
const users = await db.UserV1.findByIndex('email', 'alice@example.com');
140+
expect(users.length).toBe(1);
141+
expect(users[0].name).toBe('Alice');
142+
143+
// Test finding by index in PostV2
144+
const posts = await db.PostV2.findByIndex('authorId', 'u1');
145+
expect(posts.length).toBe(1);
146+
expect(posts[0].title).toBe('Hello World');
147+
148+
// Test finding by index in CommentV4
149+
const comments = await db.CommentV4.findByIndex('postId', 'p1');
150+
expect(comments.length).toBe(1);
151+
expect(comments[0].text).toBe('Great post!');
152+
});
153+
154+
it('should support query builder with versioned entities', async () => {
155+
const posts = await db.PostV2.query()
156+
.where('authorId').equals('u1')
157+
.execute();
158+
159+
expect(posts.length).toBe(1);
160+
expect(posts[0].title).toBe('Hello World');
161+
162+
const comments = await db.CommentV4.query()
163+
.where('userId').equals('u1')
164+
.execute();
165+
166+
expect(comments.length).toBe(1);
167+
expect(comments[0].text).toBe('Great post!');
168+
});
169+
});

index.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ function Index(): PropertyDecorator {
169169
};
170170
}
171171

172-
function DataClass(): ClassDecorator {
172+
interface DataClassOptions {
173+
version?: number;
174+
}
175+
176+
function DataClass(options: DataClassOptions = {}): ClassDecorator {
173177
return (target: Function) => {
174178
if (!Reflect.getMetadata("keypath", target)) {
175179
throw new Error(`No keypath field defined for the class ${target.name}.`);
@@ -178,7 +182,9 @@ function DataClass(): ClassDecorator {
178182
if (keyPathFields.length > 1) {
179183
throw new Error(`Only one keypath field can be defined for the class ${target.name}.`);
180184
}
185+
const version = options.version || 1;
181186
Reflect.defineMetadata("dataclass", true, target);
187+
Reflect.defineMetadata("version", version, target);
182188
};
183189
}
184190

@@ -209,13 +215,21 @@ class Database {
209215
private classes: Function[];
210216
private db: IDBDatabase | null = null;
211217
private entityRepositories: Map<string, any> = new Map();
218+
private dbVersion: number;
212219

213220
private constructor(dbName: string, classes: Function[]) {
214221
this.dbName = dbName;
215222
if (!classes.every(cls => Reflect.getMetadata("dataclass", cls))) {
216223
throw new Error("All classes should be decorated with @DataClass.");
217224
}
218225
this.classes = classes;
226+
this.dbVersion = this.calculateDatabaseVersion();
227+
}
228+
229+
private calculateDatabaseVersion(): number {
230+
// Calculate the database version based on the highest schema version
231+
const versions = this.classes.map(cls => Reflect.getMetadata("version", cls) || 1);
232+
return Math.max(...versions);
219233
}
220234

221235
public static async build<T extends Record<string, EntityRepository<any>>>(
@@ -230,32 +244,56 @@ class Database {
230244

231245
private async initDB(): Promise<void> {
232246
return new Promise((resolve, reject) => {
233-
const request = indexedDB.open(this.dbName, 1);
247+
const request = indexedDB.open(this.dbName, this.dbVersion);
234248

235-
request.onupgradeneeded = () => {
249+
request.onupgradeneeded = (event) => {
236250
const db = request.result;
251+
const oldVersion = event.oldVersion;
252+
const newVersion = event.newVersion || this.dbVersion;
237253

254+
console.debug(`Database upgrade from version ${oldVersion} to ${newVersion}`);
255+
256+
// Handle schema evolution based on versions
238257
this.classes.forEach((cls) => {
239258
const keyPathFields = Reflect.getMetadata("keypath", cls) || [];
240259
const indexFields = Reflect.getMetadata("indexes", cls) || [];
260+
const classVersion = Reflect.getMetadata("version", cls) || 1;
241261

242262
const storeName = cls.name.toLowerCase();
243263

244-
if (!db.objectStoreNames.contains(storeName)) {
245-
const store = db.createObjectStore(storeName, { keyPath: keyPathFields[0] });
246-
247-
indexFields.forEach((indexField: string) => {
248-
if (!store.indexNames.contains(indexField)) {
249-
store.createIndex(indexField, indexField, { unique: false });
264+
// Only create/update stores for classes whose version is greater than the old DB version
265+
if (classVersion > oldVersion) {
266+
if (!db.objectStoreNames.contains(storeName)) {
267+
console.debug(`Creating object store: ${storeName} (version ${classVersion})`);
268+
const store = db.createObjectStore(storeName, { keyPath: keyPathFields[0] });
269+
270+
indexFields.forEach((indexField: string) => {
271+
if (!store.indexNames.contains(indexField)) {
272+
store.createIndex(indexField, indexField, { unique: false });
273+
}
274+
});
275+
} else {
276+
// Store exists, check if we need to update indexes
277+
console.debug(`Updating object store: ${storeName} (version ${classVersion})`);
278+
const transaction = request.transaction;
279+
if (transaction) {
280+
const store = transaction.objectStore(storeName);
281+
282+
indexFields.forEach((indexField: string) => {
283+
if (!store.indexNames.contains(indexField)) {
284+
console.debug(`Adding index: ${indexField} to ${storeName}`);
285+
store.createIndex(indexField, indexField, { unique: false });
286+
}
287+
});
250288
}
251-
});
289+
}
252290
}
253291
});
254292
};
255293

256294
request.onsuccess = () => {
257295
this.db = request.result;
258-
console.debug(`Database initialized with object stores for: ${this.classes.map(cls => cls.name).join(", ")}`);
296+
console.debug(`Database initialized (version ${this.dbVersion}) with object stores for: ${this.classes.map(cls => `${cls.name}(v${Reflect.getMetadata("version", cls) || 1})`).join(", ")}`);
259297
resolve();
260298
};
261299

@@ -502,7 +540,25 @@ class Database {
502540
getAvailableEntities(): string[] {
503541
return Array.from(this.entityRepositories.keys());
504542
}
543+
544+
getDatabaseVersion(): number {
545+
return this.dbVersion;
546+
}
547+
548+
getEntityVersions(): Map<string, number> {
549+
const versions = new Map<string, number>();
550+
this.classes.forEach(cls => {
551+
const version = Reflect.getMetadata("version", cls) || 1;
552+
versions.set(cls.name, version);
553+
});
554+
return versions;
555+
}
556+
557+
getEntityVersion(entityName: string): number | undefined {
558+
const cls = this.classes.find(c => c.name === entityName);
559+
return cls ? (Reflect.getMetadata("version", cls) || 1) : undefined;
560+
}
505561
}
506562

507563
export { Database, KeyPath, DataClass, Index, EntityRepository };
508-
export type { DatabaseWithRepositories };
564+
export type { DatabaseWithRepositories, DataClassOptions };

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
1616
// "jsx": "preserve", /* Specify what JSX code is generated. */
1717
"experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18-
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
18+
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
1919
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
2020
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
2121
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */

0 commit comments

Comments
 (0)