@firtoz/drizzle-indexeddb 0.1.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/CHANGELOG.md +139 -0
- package/README.md +477 -0
- package/package.json +71 -0
- package/src/collections/indexeddb-collection.ts +915 -0
- package/src/context/useDrizzleIndexedDB.ts +36 -0
- package/src/function-migrator.ts +234 -0
- package/src/index.ts +30 -0
- package/src/snapshot-migrator.ts +420 -0
- package/src/utils.ts +15 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DrizzleIndexedDBContext,
|
|
4
|
+
useIndexedDBCollection,
|
|
5
|
+
type DrizzleIndexedDBContextValue,
|
|
6
|
+
} from "./DrizzleIndexedDBProvider";
|
|
7
|
+
|
|
8
|
+
export type UseDrizzleIndexedDBContextReturn<
|
|
9
|
+
TSchema extends Record<string, unknown>,
|
|
10
|
+
> = {
|
|
11
|
+
useCollection: <TTableName extends keyof TSchema & string>(
|
|
12
|
+
tableName: TTableName,
|
|
13
|
+
) => ReturnType<typeof useIndexedDBCollection<TSchema, TTableName>>;
|
|
14
|
+
indexedDB: IDBDatabase | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function useDrizzleIndexedDB<
|
|
18
|
+
TSchema extends Record<string, unknown>,
|
|
19
|
+
>(): UseDrizzleIndexedDBContextReturn<TSchema> {
|
|
20
|
+
const context = useContext(
|
|
21
|
+
DrizzleIndexedDBContext,
|
|
22
|
+
) as DrizzleIndexedDBContextValue<TSchema> | null;
|
|
23
|
+
|
|
24
|
+
if (!context) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"useDrizzleIndexedDBContext must be used within a DrizzleIndexedDBProvider",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
useCollection: <TTableName extends keyof TSchema & string>(
|
|
32
|
+
tableName: TTableName,
|
|
33
|
+
) => useIndexedDBCollection(context, tableName),
|
|
34
|
+
indexedDB: context.indexedDB,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// IndexedDB migrator that executes generated migration functions
|
|
2
|
+
|
|
3
|
+
export type IndexedDBMigrationFunction = (
|
|
4
|
+
db: IDBDatabase,
|
|
5
|
+
transaction: IDBTransaction,
|
|
6
|
+
) => Promise<void>;
|
|
7
|
+
|
|
8
|
+
interface MigrationRecord {
|
|
9
|
+
id: number;
|
|
10
|
+
appliedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MIGRATIONS_STORE = "__drizzle_migrations";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs IndexedDB migrations using generated migration functions
|
|
17
|
+
*
|
|
18
|
+
* Example usage:
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { migrations } from './drizzle/indexeddb-migrations';
|
|
21
|
+
* import { migrateIndexedDBWithFunctions } from '@firtoz/drizzle-indexeddb';
|
|
22
|
+
*
|
|
23
|
+
* const db = await migrateIndexedDBWithFunctions('my-db', migrations, true);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export async function migrateIndexedDBWithFunctions(
|
|
27
|
+
dbName: string,
|
|
28
|
+
migrations: IndexedDBMigrationFunction[],
|
|
29
|
+
debug: boolean = false,
|
|
30
|
+
): Promise<IDBDatabase> {
|
|
31
|
+
if (debug) {
|
|
32
|
+
console.log(
|
|
33
|
+
`[${new Date().toISOString()}] [PERF] IndexedDB migrator start for ${dbName}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// First, open the database to check which migrations have been applied
|
|
38
|
+
const currentDb = await openDatabaseForMigrationCheck(dbName);
|
|
39
|
+
|
|
40
|
+
const appliedMigrations = await getAppliedMigrations(currentDb);
|
|
41
|
+
|
|
42
|
+
const latestAppliedIdx =
|
|
43
|
+
appliedMigrations.length > 0
|
|
44
|
+
? Math.max(...appliedMigrations.map((m) => m.id))
|
|
45
|
+
: -1;
|
|
46
|
+
|
|
47
|
+
if (debug) {
|
|
48
|
+
console.log(
|
|
49
|
+
`[${new Date().toISOString()}] [PERF] Latest applied migration index: ${latestAppliedIdx} (checked ${appliedMigrations.length} migrations)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Determine which migrations need to be applied
|
|
54
|
+
const pendingMigrations = migrations
|
|
55
|
+
.map((fn, idx) => ({ fn, idx }))
|
|
56
|
+
.filter(({ idx }) => idx > latestAppliedIdx);
|
|
57
|
+
|
|
58
|
+
if (pendingMigrations.length === 0) {
|
|
59
|
+
if (debug) {
|
|
60
|
+
console.log(
|
|
61
|
+
`[${new Date().toISOString()}] [PERF] No pending migrations - database is up to date`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
currentDb.close();
|
|
65
|
+
// Re-open with correct version (migrations.length + 1 because version starts at 1)
|
|
66
|
+
const db = await openDatabase(dbName, migrations.length + 1);
|
|
67
|
+
if (debug) {
|
|
68
|
+
console.log(
|
|
69
|
+
`[${new Date().toISOString()}] [PERF] Migrator complete (no migrations needed)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return db;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (debug) {
|
|
76
|
+
console.log(
|
|
77
|
+
`[${new Date().toISOString()}] [PERF] Found ${pendingMigrations.length} pending migrations to apply`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
currentDb.close();
|
|
82
|
+
|
|
83
|
+
// Calculate the target version (number of total migrations)
|
|
84
|
+
const targetVersion = migrations.length;
|
|
85
|
+
|
|
86
|
+
// Open database with version upgrade to trigger migration
|
|
87
|
+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
88
|
+
// Use +1 here because first version is 1...
|
|
89
|
+
const request = indexedDB.open(dbName, targetVersion + 1);
|
|
90
|
+
|
|
91
|
+
request.onerror = () => reject(request.error);
|
|
92
|
+
request.onsuccess = () => {
|
|
93
|
+
resolve(request.result);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
request.onupgradeneeded = async (event) => {
|
|
97
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
98
|
+
const transaction = (event.target as IDBOpenDBRequest).transaction;
|
|
99
|
+
|
|
100
|
+
if (!transaction) {
|
|
101
|
+
reject(new Error("Transaction not available during upgrade"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (debug) {
|
|
106
|
+
console.log(
|
|
107
|
+
`[${new Date().toISOString()}] [PERF] Upgrade started: v${event.oldVersion} → v${event.newVersion}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Ensure migrations store exists
|
|
113
|
+
if (!db.objectStoreNames.contains(MIGRATIONS_STORE)) {
|
|
114
|
+
const migrationStore = db.createObjectStore(MIGRATIONS_STORE, {
|
|
115
|
+
keyPath: "id",
|
|
116
|
+
autoIncrement: false,
|
|
117
|
+
});
|
|
118
|
+
migrationStore.createIndex("appliedAt", "appliedAt", {
|
|
119
|
+
unique: false,
|
|
120
|
+
});
|
|
121
|
+
if (debug) {
|
|
122
|
+
console.log(
|
|
123
|
+
`[${new Date().toISOString()}] [PERF] Created migrations tracking store`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Apply each pending migration
|
|
129
|
+
for (const { fn, idx } of pendingMigrations) {
|
|
130
|
+
if (debug) {
|
|
131
|
+
console.log(
|
|
132
|
+
`[${new Date().toISOString()}] [PERF] Applying migration ${idx}...`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Execute the migration function
|
|
137
|
+
await fn(db, transaction);
|
|
138
|
+
|
|
139
|
+
// Record the migration
|
|
140
|
+
const migrationStore = transaction.objectStore(MIGRATIONS_STORE);
|
|
141
|
+
migrationStore.add({
|
|
142
|
+
id: idx,
|
|
143
|
+
appliedAt: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (debug) {
|
|
147
|
+
console.log(
|
|
148
|
+
`[${new Date().toISOString()}] [PERF] Migration ${idx} complete`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (debug) {
|
|
154
|
+
console.log(
|
|
155
|
+
`[${new Date().toISOString()}] [PERF] All ${pendingMigrations.length} migrations applied successfully`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error("[IndexedDBMigrator] Migration failed:", error);
|
|
160
|
+
transaction.abort();
|
|
161
|
+
reject(error);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (debug) {
|
|
167
|
+
console.log(
|
|
168
|
+
`[${new Date().toISOString()}] [PERF] Migrator complete - database ready`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return db;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Opens database for checking migrations (without triggering upgrade)
|
|
177
|
+
*/
|
|
178
|
+
async function openDatabaseForMigrationCheck(
|
|
179
|
+
dbName: string,
|
|
180
|
+
): Promise<IDBDatabase> {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const request = indexedDB.open(dbName);
|
|
183
|
+
request.onerror = () => {
|
|
184
|
+
reject(request.error);
|
|
185
|
+
};
|
|
186
|
+
request.onsuccess = () => {
|
|
187
|
+
resolve(request.result);
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Opens database with a specific version
|
|
194
|
+
*/
|
|
195
|
+
async function openDatabase(
|
|
196
|
+
dbName: string,
|
|
197
|
+
version: number,
|
|
198
|
+
): Promise<IDBDatabase> {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const request = indexedDB.open(dbName, version);
|
|
201
|
+
request.onerror = () => {
|
|
202
|
+
reject(request.error);
|
|
203
|
+
};
|
|
204
|
+
request.onsuccess = () => {
|
|
205
|
+
resolve(request.result);
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Gets the list of applied migrations from the database
|
|
212
|
+
*/
|
|
213
|
+
async function getAppliedMigrations(
|
|
214
|
+
db: IDBDatabase,
|
|
215
|
+
): Promise<MigrationRecord[]> {
|
|
216
|
+
if (!db.objectStoreNames.contains(MIGRATIONS_STORE)) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const transaction = db.transaction(MIGRATIONS_STORE, "readonly");
|
|
222
|
+
|
|
223
|
+
const store = transaction.objectStore(MIGRATIONS_STORE);
|
|
224
|
+
|
|
225
|
+
const request = store.getAll();
|
|
226
|
+
|
|
227
|
+
request.onerror = () => {
|
|
228
|
+
reject(request.error);
|
|
229
|
+
};
|
|
230
|
+
request.onsuccess = () => {
|
|
231
|
+
resolve(request.result);
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export {
|
|
2
|
+
migrateIndexedDB,
|
|
3
|
+
type IndexedDBMigrationConfig,
|
|
4
|
+
} from "./snapshot-migrator";
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
migrateIndexedDBWithFunctions,
|
|
8
|
+
type IndexedDBMigrationFunction,
|
|
9
|
+
} from "./function-migrator";
|
|
10
|
+
|
|
11
|
+
export { deleteIndexedDB } from "./utils";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
indexedDBCollectionOptions,
|
|
15
|
+
type IndexedDBCollectionConfig,
|
|
16
|
+
type IndexedDBSyncItem,
|
|
17
|
+
} from "./collections/indexeddb-collection";
|
|
18
|
+
|
|
19
|
+
// IndexedDB Provider
|
|
20
|
+
export {
|
|
21
|
+
DrizzleIndexedDBProvider,
|
|
22
|
+
DrizzleIndexedDBContext,
|
|
23
|
+
useIndexedDBCollection,
|
|
24
|
+
type DrizzleIndexedDBContextValue,
|
|
25
|
+
} from "./context/DrizzleIndexedDBProvider";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
useDrizzleIndexedDB,
|
|
29
|
+
type UseDrizzleIndexedDBContextReturn,
|
|
30
|
+
} from "./context/useDrizzleIndexedDB";
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// IndexedDB migrator that uses Drizzle snapshot files to create object stores and indexes
|
|
2
|
+
|
|
3
|
+
import type { Journal, Snapshot } from "@firtoz/drizzle-utils";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Migration Config Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export interface IndexedDBMigrationConfig {
|
|
10
|
+
journal: Journal;
|
|
11
|
+
snapshots: Record<string, Snapshot>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MigrationRecord {
|
|
15
|
+
id: number;
|
|
16
|
+
tag: string;
|
|
17
|
+
when: number;
|
|
18
|
+
appliedAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIGRATIONS_STORE = "__drizzle_migrations";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Opens an IndexedDB database and runs migrations if needed
|
|
25
|
+
*/
|
|
26
|
+
export async function migrateIndexedDB(
|
|
27
|
+
dbName: string,
|
|
28
|
+
config: IndexedDBMigrationConfig,
|
|
29
|
+
debug: boolean = false,
|
|
30
|
+
): Promise<IDBDatabase> {
|
|
31
|
+
if (debug) {
|
|
32
|
+
console.log(
|
|
33
|
+
`[${new Date().toISOString()}] [PERF] IndexedDB snapshot migrator start for ${dbName}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// First, open the database to check which migrations have been applied
|
|
38
|
+
|
|
39
|
+
const currentDb = await openDatabaseForMigrationCheck(dbName);
|
|
40
|
+
|
|
41
|
+
const appliedMigrations = await getAppliedMigrations(currentDb);
|
|
42
|
+
|
|
43
|
+
const latestAppliedIdx =
|
|
44
|
+
appliedMigrations.length > 0
|
|
45
|
+
? Math.max(...appliedMigrations.map((m) => m.id))
|
|
46
|
+
: -1;
|
|
47
|
+
|
|
48
|
+
if (debug) {
|
|
49
|
+
console.log(
|
|
50
|
+
`[${new Date().toISOString()}] [PERF] Latest applied migration index: ${latestAppliedIdx} (checked ${appliedMigrations.length} migrations)`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Determine which migrations need to be applied
|
|
55
|
+
const pendingMigrations = config.journal.entries.filter(
|
|
56
|
+
(entry) => entry.idx > latestAppliedIdx,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (pendingMigrations.length === 0) {
|
|
60
|
+
if (debug) {
|
|
61
|
+
console.log(
|
|
62
|
+
`[${new Date().toISOString()}] [PERF] No pending migrations - database is up to date`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
currentDb.close();
|
|
66
|
+
// Re-open with correct version
|
|
67
|
+
|
|
68
|
+
const db = await openDatabase(dbName, config.journal.entries.length);
|
|
69
|
+
|
|
70
|
+
if (debug) {
|
|
71
|
+
console.log(
|
|
72
|
+
`[${new Date().toISOString()}] [PERF] Migrator complete (no migrations needed)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return db;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (debug) {
|
|
79
|
+
console.log(
|
|
80
|
+
`[${new Date().toISOString()}] [PERF] Found ${pendingMigrations.length} pending migrations to apply:`,
|
|
81
|
+
pendingMigrations.map((m) => m.tag),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
currentDb.close();
|
|
86
|
+
|
|
87
|
+
// Calculate the target version (number of total migrations)
|
|
88
|
+
const targetVersion = config.journal.entries.length;
|
|
89
|
+
|
|
90
|
+
// Open database with version upgrade to trigger migration
|
|
91
|
+
|
|
92
|
+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
93
|
+
const request = indexedDB.open(dbName, targetVersion);
|
|
94
|
+
|
|
95
|
+
request.onerror = () => reject(request.error);
|
|
96
|
+
request.onsuccess = () => {
|
|
97
|
+
resolve(request.result);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
request.onupgradeneeded = (event) => {
|
|
101
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
102
|
+
const transaction = (event.target as IDBOpenDBRequest).transaction;
|
|
103
|
+
|
|
104
|
+
if (!transaction) {
|
|
105
|
+
reject(new Error("Transaction not available during upgrade"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (debug) {
|
|
110
|
+
console.log(
|
|
111
|
+
`[${new Date().toISOString()}] [PERF] Upgrade started: v${event.oldVersion} → v${event.newVersion}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Ensure migrations store exists
|
|
117
|
+
|
|
118
|
+
if (!db.objectStoreNames.contains(MIGRATIONS_STORE)) {
|
|
119
|
+
const migrationStore = db.createObjectStore(MIGRATIONS_STORE, {
|
|
120
|
+
keyPath: "id",
|
|
121
|
+
autoIncrement: false,
|
|
122
|
+
});
|
|
123
|
+
migrationStore.createIndex("tag", "tag", { unique: true });
|
|
124
|
+
migrationStore.createIndex("when", "when", { unique: false });
|
|
125
|
+
if (debug) {
|
|
126
|
+
console.log(
|
|
127
|
+
`[${new Date().toISOString()}] [PERF] Created migrations tracking store`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Apply each pending migration
|
|
133
|
+
for (const journalEntry of pendingMigrations) {
|
|
134
|
+
const snapshotKey = `m${journalEntry.idx.toString().padStart(4, "0")}`;
|
|
135
|
+
const snapshot = config.snapshots[snapshotKey];
|
|
136
|
+
|
|
137
|
+
if (!snapshot) {
|
|
138
|
+
throw new Error(`Missing snapshot: ${snapshotKey}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Get previous snapshot for comparison (if this isn't the first migration)
|
|
142
|
+
const previousSnapshot =
|
|
143
|
+
journalEntry.idx > 0
|
|
144
|
+
? config.snapshots[
|
|
145
|
+
`m${(journalEntry.idx - 1).toString().padStart(4, "0")}`
|
|
146
|
+
]
|
|
147
|
+
: null;
|
|
148
|
+
|
|
149
|
+
if (debug) {
|
|
150
|
+
console.log(
|
|
151
|
+
`[${new Date().toISOString()}] [PERF] Applying migration ${journalEntry.idx}: ${journalEntry.tag}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
applySnapshot(
|
|
156
|
+
db,
|
|
157
|
+
snapshot,
|
|
158
|
+
previousSnapshot,
|
|
159
|
+
transaction,
|
|
160
|
+
debug,
|
|
161
|
+
dbName,
|
|
162
|
+
journalEntry.idx,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Record the migration
|
|
166
|
+
const migrationStore = transaction.objectStore(MIGRATIONS_STORE);
|
|
167
|
+
migrationStore.add({
|
|
168
|
+
id: journalEntry.idx,
|
|
169
|
+
tag: journalEntry.tag,
|
|
170
|
+
when: journalEntry.when,
|
|
171
|
+
appliedAt: Date.now(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (debug) {
|
|
175
|
+
console.log(
|
|
176
|
+
`[${new Date().toISOString()}] [PERF] Migration ${journalEntry.idx} complete`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (debug) {
|
|
182
|
+
console.log(
|
|
183
|
+
`[${new Date().toISOString()}] [PERF] All ${pendingMigrations.length} migrations applied successfully`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error("[IndexedDBMigrator] Migration failed:", error);
|
|
188
|
+
transaction.abort();
|
|
189
|
+
reject(error);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (debug) {
|
|
195
|
+
console.log(
|
|
196
|
+
`[${new Date().toISOString()}] [PERF] Migrator complete - database ready`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return db;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Opens database for checking migrations (without triggering upgrade)
|
|
205
|
+
*/
|
|
206
|
+
async function openDatabaseForMigrationCheck(
|
|
207
|
+
dbName: string,
|
|
208
|
+
): Promise<IDBDatabase> {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const request = indexedDB.open(dbName);
|
|
211
|
+
request.onerror = () => reject(request.error);
|
|
212
|
+
request.onsuccess = () => resolve(request.result);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Opens database with a specific version
|
|
218
|
+
*/
|
|
219
|
+
async function openDatabase(
|
|
220
|
+
dbName: string,
|
|
221
|
+
version: number,
|
|
222
|
+
): Promise<IDBDatabase> {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const request = indexedDB.open(dbName, version);
|
|
225
|
+
request.onerror = () => reject(request.error);
|
|
226
|
+
request.onsuccess = () => resolve(request.result);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Gets the list of applied migrations from the database
|
|
232
|
+
*/
|
|
233
|
+
async function getAppliedMigrations(
|
|
234
|
+
db: IDBDatabase,
|
|
235
|
+
): Promise<MigrationRecord[]> {
|
|
236
|
+
if (!db.objectStoreNames.contains(MIGRATIONS_STORE)) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const transaction = db.transaction(MIGRATIONS_STORE, "readonly");
|
|
242
|
+
const store = transaction.objectStore(MIGRATIONS_STORE);
|
|
243
|
+
const request = store.getAll();
|
|
244
|
+
|
|
245
|
+
request.onerror = () => reject(request.error);
|
|
246
|
+
request.onsuccess = () => resolve(request.result);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Applies a snapshot to the database by creating/updating object stores and indexes
|
|
252
|
+
*/
|
|
253
|
+
function applySnapshot(
|
|
254
|
+
db: IDBDatabase,
|
|
255
|
+
snapshot: Snapshot,
|
|
256
|
+
previousSnapshot: Snapshot | null,
|
|
257
|
+
transaction: IDBTransaction,
|
|
258
|
+
debug: boolean,
|
|
259
|
+
_dbName: string,
|
|
260
|
+
_migrationIdx: number,
|
|
261
|
+
): void {
|
|
262
|
+
// Process each table in the snapshot
|
|
263
|
+
for (const [tableName, tableDefinition] of Object.entries(snapshot.tables)) {
|
|
264
|
+
const storeExists = db.objectStoreNames.contains(tableName);
|
|
265
|
+
let objectStore: IDBObjectStore;
|
|
266
|
+
|
|
267
|
+
if (!storeExists) {
|
|
268
|
+
// Create new object store
|
|
269
|
+
|
|
270
|
+
if (debug) {
|
|
271
|
+
console.log(
|
|
272
|
+
`[${new Date().toISOString()}] [PERF] Creating object store: ${tableName}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Find the primary key column (optional in IndexedDB)
|
|
277
|
+
const primaryKeyColumn = Object.entries(tableDefinition.columns).find(
|
|
278
|
+
([_, col]) => col.primaryKey,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (primaryKeyColumn) {
|
|
282
|
+
// In-line key: Use the primary key column as keyPath
|
|
283
|
+
const keyPath = primaryKeyColumn[0];
|
|
284
|
+
const autoIncrement = primaryKeyColumn[1].autoincrement;
|
|
285
|
+
|
|
286
|
+
objectStore = db.createObjectStore(tableName, {
|
|
287
|
+
keyPath,
|
|
288
|
+
autoIncrement,
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
// Out-of-line key: No keyPath, can use auto-increment or external keys
|
|
292
|
+
if (debug) {
|
|
293
|
+
console.log(
|
|
294
|
+
`[${new Date().toISOString()}] [PERF] Creating object store ${tableName} with out-of-line keys (no keyPath)`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
objectStore = db.createObjectStore(tableName, {
|
|
298
|
+
autoIncrement: true, // Auto-generate keys by default
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
// Get existing object store
|
|
303
|
+
objectStore = transaction.objectStore(tableName);
|
|
304
|
+
|
|
305
|
+
// Check if the primary key or autoIncrement has changed
|
|
306
|
+
const primaryKeyColumn = Object.entries(tableDefinition.columns).find(
|
|
307
|
+
([_, col]) => col.primaryKey,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Determine expected key configuration
|
|
311
|
+
const expectedKeyPath = primaryKeyColumn ? primaryKeyColumn[0] : null;
|
|
312
|
+
const expectedAutoIncrement = primaryKeyColumn
|
|
313
|
+
? primaryKeyColumn[1].autoincrement
|
|
314
|
+
: true; // Default for out-of-line keys
|
|
315
|
+
|
|
316
|
+
// Normalize keyPath for comparison (null, undefined, or empty string are all "no keyPath")
|
|
317
|
+
const currentKeyPath = objectStore.keyPath || null;
|
|
318
|
+
const normalizedExpectedKeyPath = expectedKeyPath || null;
|
|
319
|
+
|
|
320
|
+
// Warn if key structure changed - this requires manual migration
|
|
321
|
+
if (
|
|
322
|
+
currentKeyPath !== normalizedExpectedKeyPath ||
|
|
323
|
+
objectStore.autoIncrement !== expectedAutoIncrement
|
|
324
|
+
) {
|
|
325
|
+
const message =
|
|
326
|
+
`[IndexedDBMigrator] Primary key structure changed for ${tableName}. ` +
|
|
327
|
+
`This requires manual migration. ` +
|
|
328
|
+
`Old keyPath: ${currentKeyPath || "(none)"}, new keyPath: ${normalizedExpectedKeyPath || "(none)"}. ` +
|
|
329
|
+
`Old autoIncrement: ${objectStore.autoIncrement}, new autoIncrement: ${expectedAutoIncrement}. ` +
|
|
330
|
+
`Consider exporting data, deleting the database, and re-importing.`;
|
|
331
|
+
|
|
332
|
+
console.error(message);
|
|
333
|
+
throw new Error(message);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const currentIndexes = tableDefinition.indexes;
|
|
338
|
+
|
|
339
|
+
// Remove indexes that no longer exist in the schema
|
|
340
|
+
const existingIndexNames = Array.from(objectStore.indexNames);
|
|
341
|
+
for (const existingIndexName of existingIndexNames) {
|
|
342
|
+
if (!currentIndexes[existingIndexName]) {
|
|
343
|
+
if (debug) {
|
|
344
|
+
console.log(
|
|
345
|
+
`[${new Date().toISOString()}] [PERF] Deleting index: ${existingIndexName} from ${tableName}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
objectStore.deleteIndex(existingIndexName);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Add or update indexes
|
|
353
|
+
for (const [indexName, indexDef] of Object.entries(currentIndexes)) {
|
|
354
|
+
const indexExists = objectStore.indexNames.contains(indexName);
|
|
355
|
+
|
|
356
|
+
// IndexedDB indexes can only be on a single column or use an array keyPath
|
|
357
|
+
const keyPath =
|
|
358
|
+
indexDef.columns.length === 1 ? indexDef.columns[0] : indexDef.columns;
|
|
359
|
+
|
|
360
|
+
if (indexExists) {
|
|
361
|
+
// Check if index definition has changed
|
|
362
|
+
const existingIndex = objectStore.index(indexName);
|
|
363
|
+
const keyPathChanged = Array.isArray(keyPath)
|
|
364
|
+
? JSON.stringify(existingIndex.keyPath) !== JSON.stringify(keyPath)
|
|
365
|
+
: existingIndex.keyPath !== keyPath;
|
|
366
|
+
const uniqueChanged = existingIndex.unique !== indexDef.isUnique;
|
|
367
|
+
|
|
368
|
+
if (keyPathChanged || uniqueChanged) {
|
|
369
|
+
if (debug) {
|
|
370
|
+
console.log(
|
|
371
|
+
`[${new Date().toISOString()}] [PERF] Recreating index: ${indexName} on ${tableName}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
// Recreate the index
|
|
375
|
+
objectStore.deleteIndex(indexName);
|
|
376
|
+
objectStore.createIndex(indexName, keyPath, {
|
|
377
|
+
unique: indexDef.isUnique,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
// Create new index
|
|
382
|
+
|
|
383
|
+
if (debug) {
|
|
384
|
+
console.log(
|
|
385
|
+
`[${new Date().toISOString()}] [PERF] Creating index: ${indexName} on ${tableName}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
objectStore.createIndex(indexName, keyPath, {
|
|
391
|
+
unique: indexDef.isUnique,
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error(
|
|
395
|
+
`[${new Date().toISOString()}] [IndexedDBMigrator] failed to create index ${indexName}:`,
|
|
396
|
+
error,
|
|
397
|
+
);
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Handle deleted tables
|
|
405
|
+
if (previousSnapshot) {
|
|
406
|
+
for (const tableName of Object.keys(previousSnapshot.tables)) {
|
|
407
|
+
if (
|
|
408
|
+
!snapshot.tables[tableName] &&
|
|
409
|
+
db.objectStoreNames.contains(tableName)
|
|
410
|
+
) {
|
|
411
|
+
if (debug) {
|
|
412
|
+
console.log(
|
|
413
|
+
`[${new Date().toISOString()}] [PERF] Deleting object store: ${tableName}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
db.deleteObjectStore(tableName);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|