@indexeddb-orm/idb-orm 0.0.1
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/.vscode/extensions.json +5 -0
- package/README.md +1280 -0
- package/angular-demo-app/README.md +84 -0
- package/angular-demo-app/angular.json +109 -0
- package/angular-demo-app/package-lock.json +14215 -0
- package/angular-demo-app/package.json +41 -0
- package/angular-demo-app/src/app/app.component.ts +481 -0
- package/angular-demo-app/src/app/app.routes.ts +8 -0
- package/angular-demo-app/src/app/components/actions.component.ts +202 -0
- package/angular-demo-app/src/app/components/cloud-sync-demo.component.ts +296 -0
- package/angular-demo-app/src/app/components/live-query-demo.component.ts +307 -0
- package/angular-demo-app/src/app/components/main-info.component.ts +148 -0
- package/angular-demo-app/src/app/components/posts-live-query-demo.component.ts +336 -0
- package/angular-demo-app/src/app/components/typescript-demo.component.ts +268 -0
- package/angular-demo-app/src/entities/post-tag.entity.ts +25 -0
- package/angular-demo-app/src/entities/post.entity.ts +49 -0
- package/angular-demo-app/src/entities/profile.entity.ts +42 -0
- package/angular-demo-app/src/entities/tag.entity.ts +36 -0
- package/angular-demo-app/src/entities/user.entity.ts +59 -0
- package/angular-demo-app/src/favicon.ico +1 -0
- package/angular-demo-app/src/index.html +16 -0
- package/angular-demo-app/src/main.ts +13 -0
- package/angular-demo-app/src/services/app-logic.service.ts +449 -0
- package/angular-demo-app/src/services/cloud-sync.service.ts +95 -0
- package/angular-demo-app/src/services/database.service.ts +26 -0
- package/angular-demo-app/src/services/live-query.service.ts +63 -0
- package/angular-demo-app/src/services/posts-live-query.service.ts +86 -0
- package/angular-demo-app/src/services/typescript-demo.service.ts +59 -0
- package/angular-demo-app/src/styles.scss +50 -0
- package/angular-demo-app/tsconfig.app.json +13 -0
- package/angular-demo-app/tsconfig.json +34 -0
- package/angular-demo-app/tsconfig.spec.json +13 -0
- package/dist/Database.d.ts +206 -0
- package/dist/Database.js +288 -0
- package/dist/decorators/Column.d.ts +79 -0
- package/dist/decorators/Column.js +236 -0
- package/dist/decorators/Entity.d.ts +32 -0
- package/dist/decorators/Entity.js +44 -0
- package/dist/decorators/Relation.d.ts +70 -0
- package/dist/decorators/Relation.js +120 -0
- package/dist/decorators/index.d.ts +3 -0
- package/dist/decorators/index.js +3 -0
- package/dist/errors/ValidationError.d.ts +4 -0
- package/dist/errors/ValidationError.js +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/metadata/Column.d.ts +8 -0
- package/dist/metadata/Column.js +44 -0
- package/dist/metadata/Entity.d.ts +11 -0
- package/dist/metadata/Entity.js +21 -0
- package/dist/metadata/Relation.d.ts +20 -0
- package/dist/metadata/Relation.js +74 -0
- package/dist/metadata/index.d.ts +3 -0
- package/dist/metadata/index.js +3 -0
- package/dist/services/AggregationService.d.ts +38 -0
- package/dist/services/AggregationService.js +229 -0
- package/dist/services/BaseEntity.d.ts +32 -0
- package/dist/services/BaseEntity.js +62 -0
- package/dist/services/CloudSyncService.d.ts +100 -0
- package/dist/services/CloudSyncService.js +196 -0
- package/dist/services/DecoratorUtils.d.ts +12 -0
- package/dist/services/DecoratorUtils.js +10 -0
- package/dist/services/EntityFactory.d.ts +25 -0
- package/dist/services/EntityFactory.js +27 -0
- package/dist/services/EntityRegistry.d.ts +61 -0
- package/dist/services/EntityRegistry.js +56 -0
- package/dist/services/EntitySchema.d.ts +56 -0
- package/dist/services/EntitySchema.js +125 -0
- package/dist/services/MigrationManager.d.ts +70 -0
- package/dist/services/MigrationManager.js +181 -0
- package/dist/services/RelationLoader.d.ts +66 -0
- package/dist/services/RelationLoader.js +310 -0
- package/dist/services/SchemaBuilder.d.ts +68 -0
- package/dist/services/SchemaBuilder.js +191 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.js +7 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.js +1 -0
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +16 -0
- package/eslint.config.js +49 -0
- package/homepage/favicon.svg +36 -0
- package/homepage/index.html +1725 -0
- package/package.json +78 -0
- package/react-demo-app/README.md +61 -0
- package/react-demo-app/eslint.config.js +60 -0
- package/react-demo-app/index.html +13 -0
- package/react-demo-app/package-lock.json +4955 -0
- package/react-demo-app/package.json +39 -0
- package/react-demo-app/src/App.tsx +172 -0
- package/react-demo-app/src/assets/react.svg +1 -0
- package/react-demo-app/src/components/Actions.tsx +171 -0
- package/react-demo-app/src/components/CloudSyncDemo.tsx +191 -0
- package/react-demo-app/src/components/LiveQueryDemo.tsx +122 -0
- package/react-demo-app/src/components/MainInfo.tsx +75 -0
- package/react-demo-app/src/components/PostsLiveQueryDemo.tsx +185 -0
- package/react-demo-app/src/components/TypeScriptDemo.tsx +190 -0
- package/react-demo-app/src/database/Database.ts +30 -0
- package/react-demo-app/src/entities/Post.ts +48 -0
- package/react-demo-app/src/entities/PostTag.ts +26 -0
- package/react-demo-app/src/entities/Profile.ts +41 -0
- package/react-demo-app/src/entities/Tag.ts +35 -0
- package/react-demo-app/src/entities/User.ts +61 -0
- package/react-demo-app/src/hooks/useAppLogic.ts +565 -0
- package/react-demo-app/src/hooks/useCloudSyncDemo.ts +84 -0
- package/react-demo-app/src/hooks/useLiveQueryDemo.ts +68 -0
- package/react-demo-app/src/hooks/usePostsLiveQueryDemo.ts +64 -0
- package/react-demo-app/src/hooks/useTypeScriptDemo.ts +43 -0
- package/react-demo-app/src/index.css +26 -0
- package/react-demo-app/src/main.tsx +18 -0
- package/react-demo-app/src/migrations/001-add-user-email-index.ts +17 -0
- package/react-demo-app/src/migrations/002-add-post-category.ts +37 -0
- package/react-demo-app/src/migrations/index.ts +8 -0
- package/react-demo-app/src/vite-env.d.ts +1 -0
- package/react-demo-app/tsconfig.app.json +22 -0
- package/react-demo-app/tsconfig.json +6 -0
- package/react-demo-app/vite.config.ts +10 -0
- package/src/Database.ts +405 -0
- package/src/errors/ValidationError.ts +9 -0
- package/src/index.ts +13 -0
- package/src/metadata/Column.ts +74 -0
- package/src/metadata/Entity.ts +42 -0
- package/src/metadata/Relation.ts +121 -0
- package/src/metadata/index.ts +5 -0
- package/src/services/AggregationService.ts +348 -0
- package/src/services/BaseEntity.ts +77 -0
- package/src/services/CloudSyncService.ts +248 -0
- package/src/services/EntityFactory.ts +35 -0
- package/src/services/EntityRegistry.ts +109 -0
- package/src/services/EntitySchema.ts +154 -0
- package/src/services/MigrationManager.ts +276 -0
- package/src/services/RelationLoader.ts +532 -0
- package/src/services/SchemaBuilder.ts +237 -0
- package/src/services/index.ts +7 -0
- package/src/types.d.ts +1 -0
- package/src/types.ts +169 -0
- package/src/utils/logger.ts +40 -0
- package/svelte-demo-app/README.md +61 -0
- package/svelte-demo-app/package-lock.json +3000 -0
- package/svelte-demo-app/package.json +30 -0
- package/svelte-demo-app/src/app.d.ts +12 -0
- package/svelte-demo-app/src/app.html +13 -0
- package/svelte-demo-app/src/components/Actions.svelte +121 -0
- package/svelte-demo-app/src/components/CloudSyncDemo.svelte +333 -0
- package/svelte-demo-app/src/components/LiveQueryDemo.svelte +191 -0
- package/svelte-demo-app/src/components/MainInfo.svelte +133 -0
- package/svelte-demo-app/src/components/PostsLiveQueryDemo.svelte +330 -0
- package/svelte-demo-app/src/components/TypeScriptDemo.svelte +251 -0
- package/svelte-demo-app/src/database/Database.ts +29 -0
- package/svelte-demo-app/src/entities/Post.ts +46 -0
- package/svelte-demo-app/src/entities/PostTag.ts +22 -0
- package/svelte-demo-app/src/entities/Profile.ts +39 -0
- package/svelte-demo-app/src/entities/Tag.ts +33 -0
- package/svelte-demo-app/src/entities/User.ts +62 -0
- package/svelte-demo-app/src/lib/database/Database.ts +30 -0
- package/svelte-demo-app/src/lib/entities/Post.ts +47 -0
- package/svelte-demo-app/src/lib/entities/PostTag.ts +23 -0
- package/svelte-demo-app/src/lib/entities/Profile.ts +40 -0
- package/svelte-demo-app/src/lib/entities/Tag.ts +34 -0
- package/svelte-demo-app/src/lib/entities/User.ts +59 -0
- package/svelte-demo-app/src/lib/index.ts +7 -0
- package/svelte-demo-app/src/lib/migrations/001-add-user-email-index.ts +17 -0
- package/svelte-demo-app/src/lib/migrations/002-add-post-category.ts +37 -0
- package/svelte-demo-app/src/lib/migrations/index.ts +8 -0
- package/svelte-demo-app/src/migrations/001-add-user-email-index.ts +17 -0
- package/svelte-demo-app/src/migrations/002-add-post-category.ts +37 -0
- package/svelte-demo-app/src/migrations/index.ts +8 -0
- package/svelte-demo-app/src/routes/+layout.js +3 -0
- package/svelte-demo-app/src/routes/+layout.svelte +228 -0
- package/svelte-demo-app/src/routes/+page.js +3 -0
- package/svelte-demo-app/src/routes/+page.svelte +1305 -0
- package/svelte-demo-app/src/stores/appStore.js +603 -0
- package/svelte-demo-app/svelte.config.js +18 -0
- package/svelte-demo-app/tsconfig.json +14 -0
- package/svelte-demo-app/vite.config.ts +6 -0
- package/tests/aggregation.e2e.test.ts +87 -0
- package/tests/base-entity.e2e.test.ts +47 -0
- package/tests/database-api.e2e.test.ts +177 -0
- package/tests/decorators.e2e.test.ts +40 -0
- package/tests/entity-schema.e2e.test.ts +58 -0
- package/tests/relation-loader-table-names.test.ts +192 -0
- package/tests/relations.e2e.test.ts +178 -0
- package/tests/zod-runtime.e2e.test.ts +69 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +21 -0
- package/vitest.setup.ts +27 -0
- package/vue-demo-app/README.md +61 -0
- package/vue-demo-app/index.html +13 -0
- package/vue-demo-app/package-lock.json +1537 -0
- package/vue-demo-app/package.json +27 -0
- package/vue-demo-app/src/App.vue +100 -0
- package/vue-demo-app/src/components/Actions.vue +135 -0
- package/vue-demo-app/src/components/CloudSyncDemo.vue +139 -0
- package/vue-demo-app/src/components/LiveQueryDemo.vue +122 -0
- package/vue-demo-app/src/components/MainInfo.vue +80 -0
- package/vue-demo-app/src/components/PostsLiveQueryDemo.vue +136 -0
- package/vue-demo-app/src/components/TypeScriptDemo.vue +133 -0
- package/vue-demo-app/src/database/Database.ts +29 -0
- package/vue-demo-app/src/entities/Post.ts +48 -0
- package/vue-demo-app/src/entities/PostTag.ts +24 -0
- package/vue-demo-app/src/entities/Profile.ts +41 -0
- package/vue-demo-app/src/entities/Tag.ts +35 -0
- package/vue-demo-app/src/entities/User.ts +61 -0
- package/vue-demo-app/src/main.ts +29 -0
- package/vue-demo-app/src/migrations/001-add-user-email-index.ts +23 -0
- package/vue-demo-app/src/migrations/002-add-post-category.ts +46 -0
- package/vue-demo-app/src/migrations/index.ts +14 -0
- package/vue-demo-app/src/services/useAppLogic.ts +565 -0
- package/vue-demo-app/src/services/useCloudSyncDemo.ts +84 -0
- package/vue-demo-app/src/services/useLiveQueryDemo.ts +82 -0
- package/vue-demo-app/src/services/usePostsLiveQueryDemo.ts +77 -0
- package/vue-demo-app/src/services/useTypeScriptDemo.ts +56 -0
- package/vue-demo-app/src/vite-env.d.ts +1 -0
- package/vue-demo-app/tsconfig.json +25 -0
- package/vue-demo-app/tsconfig.node.json +10 -0
- package/vue-demo-app/vite.config.ts +16 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { Database } from '../Database';
|
|
2
|
+
import { getRelationMetadata } from '../metadata/Relation';
|
|
3
|
+
import type {
|
|
4
|
+
AggregationOptions,
|
|
5
|
+
AggregationResult,
|
|
6
|
+
EntityConstructor,
|
|
7
|
+
RelationMetadata,
|
|
8
|
+
} from '../types';
|
|
9
|
+
import { BaseEntity } from './BaseEntity';
|
|
10
|
+
|
|
11
|
+
export class AggregationService {
|
|
12
|
+
private db: Database;
|
|
13
|
+
|
|
14
|
+
constructor(database: Database) {
|
|
15
|
+
this.db = database;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Perform aggregation operations on entity data
|
|
20
|
+
* @param entityClass - The entity class to aggregate
|
|
21
|
+
* @param options - Aggregation options including filters, sorting, grouping, etc.
|
|
22
|
+
* @returns Promise resolving to aggregation results
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const result = await aggregationService.aggregate(Post, {
|
|
26
|
+
* where: { category: 'tech' },
|
|
27
|
+
* sort: { field: 'views', direction: 'desc' },
|
|
28
|
+
* limit: 10,
|
|
29
|
+
* count: true,
|
|
30
|
+
* sum: ['views'],
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
33
|
+
async aggregate<T extends BaseEntity>(
|
|
34
|
+
entityClass: EntityConstructor<T>,
|
|
35
|
+
options: AggregationOptions<T>,
|
|
36
|
+
): Promise<AggregationResult> {
|
|
37
|
+
const table = this.db.getRepository(entityClass);
|
|
38
|
+
let collection = table.toCollection();
|
|
39
|
+
|
|
40
|
+
if (options.where) {
|
|
41
|
+
const whereConditions = options.where;
|
|
42
|
+
collection = collection.filter((record: T) => {
|
|
43
|
+
return Object.entries(whereConditions).every(([key, value]) => {
|
|
44
|
+
return record[key as keyof T] === value;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let records = await collection.toArray();
|
|
50
|
+
|
|
51
|
+
if (options.include && options.include.length > 0) {
|
|
52
|
+
const relations = getRelationMetadata(entityClass);
|
|
53
|
+
if (relations) {
|
|
54
|
+
for (const record of records) {
|
|
55
|
+
for (const relationName of options.include) {
|
|
56
|
+
if (relations[relationName]) {
|
|
57
|
+
(record as Record<string, unknown>)[relationName] =
|
|
58
|
+
await this.loadRelation(
|
|
59
|
+
record as Record<string, unknown>,
|
|
60
|
+
relations[relationName],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.sort) {
|
|
69
|
+
const sortField = options.sort.field;
|
|
70
|
+
const sortDirection = options.sort.direction;
|
|
71
|
+
|
|
72
|
+
records.sort((a: T, b: T) => {
|
|
73
|
+
const aVal = a[sortField];
|
|
74
|
+
const bVal = b[sortField];
|
|
75
|
+
const direction = sortDirection === 'asc' ? 1 : -1;
|
|
76
|
+
|
|
77
|
+
if (aVal < bVal) {
|
|
78
|
+
return -1 * direction;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (aVal > bVal) {
|
|
82
|
+
return 1 * direction;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.limit) {
|
|
90
|
+
records = records.slice(0, options.limit);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result: AggregationResult = {};
|
|
94
|
+
|
|
95
|
+
if (options.count) {
|
|
96
|
+
result.count = records.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.sum || options.avg || options.min || options.max) {
|
|
100
|
+
const numericFields = [
|
|
101
|
+
...(options.sum || []),
|
|
102
|
+
...(options.avg || []),
|
|
103
|
+
...(options.min || []),
|
|
104
|
+
...(options.max || []),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const field of numericFields) {
|
|
108
|
+
const values = records
|
|
109
|
+
.map((r: T) => r[field])
|
|
110
|
+
.filter((v: unknown) => typeof v === 'number' && !isNaN(v)) as number[];
|
|
111
|
+
|
|
112
|
+
if (values.length === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.sum?.includes(field)) {
|
|
117
|
+
result.sum = result.sum || {};
|
|
118
|
+
|
|
119
|
+
result.sum[field as string] = values.reduce((sum, val) => sum + val, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (options.avg?.includes(field)) {
|
|
123
|
+
result.avg = result.avg || {};
|
|
124
|
+
|
|
125
|
+
result.avg[field as string] = values
|
|
126
|
+
.reduce((sum, val) => sum + val, 0) / values.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (options.min?.includes(field)) {
|
|
130
|
+
result.min = result.min || {};
|
|
131
|
+
|
|
132
|
+
result.min[field as string] = Math.min(...values);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.max?.includes(field)) {
|
|
136
|
+
result.max = result.max || {};
|
|
137
|
+
|
|
138
|
+
result.max[field as string] = Math.max(...values);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (options.groupBy) {
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
const groups = new Map<any, any[]>();
|
|
146
|
+
|
|
147
|
+
for (const record of records) {
|
|
148
|
+
const key = record[options.groupBy];
|
|
149
|
+
|
|
150
|
+
if (!groups.has(key)) {
|
|
151
|
+
groups.set(key, []);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const group = groups.get(key);
|
|
155
|
+
|
|
156
|
+
if (group) {
|
|
157
|
+
group.push(record);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
result.groups = Array.from(groups.entries()).map(([key, groupRecords]) => {
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
const groupResult: any = { key, count: groupRecords.length };
|
|
164
|
+
|
|
165
|
+
if (options.sum || options.avg || options.min || options.max) {
|
|
166
|
+
const numericFields = [
|
|
167
|
+
...(options.sum || []),
|
|
168
|
+
...(options.avg || []),
|
|
169
|
+
...(options.min || []),
|
|
170
|
+
...(options.max || []),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const field of numericFields) {
|
|
174
|
+
const values = groupRecords
|
|
175
|
+
.map(r => r[field])
|
|
176
|
+
.filter(v => typeof v === 'number' && !isNaN(v)) as number[];
|
|
177
|
+
|
|
178
|
+
if (values.length === 0) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (options.sum?.includes(field)) {
|
|
183
|
+
groupResult.sum = groupResult.sum || {};
|
|
184
|
+
|
|
185
|
+
groupResult.sum[field as string] = values
|
|
186
|
+
.reduce((sum, val) => sum + val, 0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (options.avg?.includes(field)) {
|
|
190
|
+
groupResult.avg = groupResult.avg || {};
|
|
191
|
+
|
|
192
|
+
groupResult.avg[field as string] = values.reduce(
|
|
193
|
+
(sum, val) => sum + val,
|
|
194
|
+
0,
|
|
195
|
+
) / values.length;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (options.min?.includes(field)) {
|
|
199
|
+
groupResult.min = groupResult.min || {};
|
|
200
|
+
|
|
201
|
+
groupResult.min[field as string] = Math.min(...values);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (options.max?.includes(field)) {
|
|
205
|
+
groupResult.max = groupResult.max || {};
|
|
206
|
+
|
|
207
|
+
groupResult.max[field as string] = Math.max(...values);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return groupResult;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (
|
|
217
|
+
!options.count &&
|
|
218
|
+
!options.sum &&
|
|
219
|
+
!options.avg &&
|
|
220
|
+
!options.min &&
|
|
221
|
+
!options.max &&
|
|
222
|
+
!options.groupBy
|
|
223
|
+
) {
|
|
224
|
+
result.data = records;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Load a specific relation based on metadata.
|
|
232
|
+
* @param entity - The source entity to load relation for
|
|
233
|
+
* @param relationMeta - Relation metadata (type, target, keys)
|
|
234
|
+
* @returns Promise resolving to the loaded relation data
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* const related = await (aggregationService as any).loadRelation(record, {
|
|
238
|
+
* type: 'one-to-many', target: Post, foreignKey: 'authorId',
|
|
239
|
+
* });
|
|
240
|
+
*/
|
|
241
|
+
private async loadRelation(
|
|
242
|
+
entity: Record<string, unknown>,
|
|
243
|
+
relationMeta: RelationMetadata,
|
|
244
|
+
): Promise<unknown> {
|
|
245
|
+
const targetTable = this.db
|
|
246
|
+
.getRepository(relationMeta.target as EntityConstructor);
|
|
247
|
+
|
|
248
|
+
switch (relationMeta.type) {
|
|
249
|
+
case 'one-to-one':
|
|
250
|
+
return await this.loadOneToOne(entity, targetTable, relationMeta);
|
|
251
|
+
|
|
252
|
+
case 'one-to-many':
|
|
253
|
+
return await this.loadOneToMany(entity, targetTable, relationMeta);
|
|
254
|
+
|
|
255
|
+
case 'many-to-many':
|
|
256
|
+
return await this.loadManyToMany(entity, targetTable, relationMeta);
|
|
257
|
+
|
|
258
|
+
default:
|
|
259
|
+
throw new Error(`Unsupported relation type: ${relationMeta.type}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async loadOneToOne(
|
|
264
|
+
entity: Record<string, unknown>,
|
|
265
|
+
_targetTable: unknown,
|
|
266
|
+
relationMeta: RelationMetadata,
|
|
267
|
+
): Promise<unknown> {
|
|
268
|
+
const targetTableInstance = this.db.getRepository(
|
|
269
|
+
relationMeta.target as EntityConstructor,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (relationMeta.foreignKey) {
|
|
273
|
+
return await (targetTableInstance as {
|
|
274
|
+
where: (_key: string) => {
|
|
275
|
+
equals: (_value: unknown) => {
|
|
276
|
+
first: () => Promise<unknown>;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
})
|
|
280
|
+
.where(relationMeta.foreignKey)
|
|
281
|
+
.equals(entity.id)
|
|
282
|
+
.first();
|
|
283
|
+
} else {
|
|
284
|
+
const foreignKeyValue = relationMeta.foreignKey
|
|
285
|
+
? entity[relationMeta.foreignKey] : undefined;
|
|
286
|
+
if (!foreignKeyValue) return null;
|
|
287
|
+
|
|
288
|
+
return await (targetTableInstance as {
|
|
289
|
+
get: (_id: unknown) => Promise<unknown>;
|
|
290
|
+
}).get(foreignKeyValue);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async loadOneToMany(
|
|
295
|
+
entity: Record<string, unknown>,
|
|
296
|
+
_targetTable: unknown,
|
|
297
|
+
relationMeta: RelationMetadata,
|
|
298
|
+
): Promise<unknown[]> {
|
|
299
|
+
const targetTableInstance = this.db.getRepository(
|
|
300
|
+
relationMeta.target as EntityConstructor,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
return await (targetTableInstance as {
|
|
304
|
+
where: (_key: string) => {
|
|
305
|
+
equals: (_value: unknown) => {
|
|
306
|
+
toArray: () => Promise<unknown[]>;
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
})
|
|
310
|
+
.where(relationMeta.foreignKey as string)
|
|
311
|
+
.equals(entity.id)
|
|
312
|
+
.toArray();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async loadManyToMany(
|
|
316
|
+
entity: Record<string, unknown>,
|
|
317
|
+
_targetTable: unknown,
|
|
318
|
+
relationMeta: RelationMetadata,
|
|
319
|
+
): Promise<unknown[]> {
|
|
320
|
+
const joinTable = this.db.table(relationMeta.joinTable as string);
|
|
321
|
+
const joinRecords = await joinTable
|
|
322
|
+
.where('sourceId')
|
|
323
|
+
.equals(entity.id as string | number)
|
|
324
|
+
.toArray();
|
|
325
|
+
const targetIds = joinRecords.map(
|
|
326
|
+
(record: Record<string, unknown>) => record.targetId,
|
|
327
|
+
) as (string | number)[];
|
|
328
|
+
|
|
329
|
+
if (targetIds.length === 0) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const targetTableInstance = this.db.getRepository(
|
|
334
|
+
relationMeta.target as EntityConstructor,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return await (targetTableInstance as {
|
|
338
|
+
where: (_key: string) => {
|
|
339
|
+
anyOf: (_values: unknown[]) => {
|
|
340
|
+
toArray: () => Promise<unknown[]>;
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
})
|
|
344
|
+
.where('id')
|
|
345
|
+
.anyOf(targetIds)
|
|
346
|
+
.toArray();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../errors/ValidationError';
|
|
4
|
+
import type { ValidationResult } from '../types';
|
|
5
|
+
|
|
6
|
+
export abstract class BaseEntity<TKey extends string | number = number | string> {
|
|
7
|
+
id?: TKey;
|
|
8
|
+
|
|
9
|
+
constructor(data?: Partial<BaseEntity<TKey>>) {
|
|
10
|
+
if (data) {
|
|
11
|
+
Object.assign(this, data);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate entity data against its schema
|
|
17
|
+
* @returns ValidationResult containing validation status and errors
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const result = user.validate();
|
|
21
|
+
* if (!result.isValid) console.error(result.errors);
|
|
22
|
+
*/
|
|
23
|
+
validate(): ValidationResult {
|
|
24
|
+
const constructor = this.constructor as typeof BaseEntity
|
|
25
|
+
& { schema?: z.ZodSchema<unknown> };
|
|
26
|
+
|
|
27
|
+
if (!constructor.schema) {
|
|
28
|
+
return { isValid: true, errors: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
constructor.schema.parse(this);
|
|
33
|
+
|
|
34
|
+
return { isValid: true, errors: [] };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof z.ZodError) {
|
|
37
|
+
const errors = error.issues.map(err => `${err.path.join('.')}: ${err.message}`);
|
|
38
|
+
|
|
39
|
+
return { isValid: false, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { isValid: false, errors: ['Unknown validation error'] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate entity data and throw error if invalid
|
|
48
|
+
* @throws ValidationError if validation fails
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* user.validateOrThrow(); // throws ValidationError on invalid data
|
|
52
|
+
*/
|
|
53
|
+
validateOrThrow(): void {
|
|
54
|
+
const result = this.validate();
|
|
55
|
+
|
|
56
|
+
if (!result.isValid) {
|
|
57
|
+
throw new ValidationError('Entity validation failed', result.errors);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Initialize entity with data and validate
|
|
63
|
+
* @param data - Partial data to initialize entity with
|
|
64
|
+
* @returns The initialized entity instance
|
|
65
|
+
* @throws ValidationError if validation fails
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const user = new User().init({ name: 'Alice' });
|
|
69
|
+
*/
|
|
70
|
+
init(data: Partial<this>): this {
|
|
71
|
+
Object.assign(this, data);
|
|
72
|
+
|
|
73
|
+
this.validateOrThrow();
|
|
74
|
+
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Database } from '../Database';
|
|
2
|
+
import type { CloudSyncConfig } from '../types';
|
|
3
|
+
import { createServiceLogger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
const logger = createServiceLogger('CloudSyncService');
|
|
6
|
+
|
|
7
|
+
export class CloudSyncService {
|
|
8
|
+
private db: Database;
|
|
9
|
+
private cloudSyncConfig?: CloudSyncConfig;
|
|
10
|
+
private cloudSyncEnabled = false;
|
|
11
|
+
private syncIntervalId?: number;
|
|
12
|
+
|
|
13
|
+
declare cloud: {
|
|
14
|
+
sync: () => Promise<void>;
|
|
15
|
+
lastSync?: number;
|
|
16
|
+
isOnline?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
constructor(database: Database) {
|
|
20
|
+
this.db = database;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialize cloud synchronization with configuration
|
|
25
|
+
* @param config - Cloud sync configuration
|
|
26
|
+
* @returns Promise that resolves when initialization is complete
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* await cloudService.initializeCloudSync({
|
|
30
|
+
* databaseUrl: 'https://example.cloud',
|
|
31
|
+
* enableOfflineSupport: true,
|
|
32
|
+
* syncInterval: 30000,
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
async initializeCloudSync(config: CloudSyncConfig): Promise<void> {
|
|
36
|
+
this.cloudSyncConfig = config;
|
|
37
|
+
|
|
38
|
+
if (!this.cloudSyncConfig) {
|
|
39
|
+
logger.error('Cloud sync config not provided');
|
|
40
|
+
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const cloudApi =
|
|
46
|
+
(this.db as unknown as { cloud?: { configure: (_config: unknown) => void } })
|
|
47
|
+
.cloud;
|
|
48
|
+
|
|
49
|
+
if (!cloudApi) {
|
|
50
|
+
logger.error('Dexie cloud addon not detected. Initialize Dexie with dexie-cloud-addon: new Dexie(name, { addons: [dexieCloud] })');
|
|
51
|
+
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
logger.info('Initializing cloud sync...');
|
|
56
|
+
|
|
57
|
+
cloudApi.configure({
|
|
58
|
+
databaseUrl: this.cloudSyncConfig.databaseUrl,
|
|
59
|
+
enableOfflineSupport: this.cloudSyncConfig.enableOfflineSupport ?? true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.cloudSyncEnabled = true;
|
|
63
|
+
|
|
64
|
+
logger.info('Cloud sync initialized successfully');
|
|
65
|
+
|
|
66
|
+
if (this.cloudSyncConfig.syncInterval
|
|
67
|
+
&& this.cloudSyncConfig.syncInterval > 0) {
|
|
68
|
+
this.startPeriodicSync();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.error('Failed to initialize cloud sync:', error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private startPeriodicSync(): void {
|
|
77
|
+
if (!this.cloudSyncConfig?.syncInterval) return;
|
|
78
|
+
|
|
79
|
+
this.syncIntervalId = window.setInterval(async () => {
|
|
80
|
+
try {
|
|
81
|
+
await this.sync();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error('Periodic sync failed:', error);
|
|
84
|
+
}
|
|
85
|
+
}, this.cloudSyncConfig.syncInterval);
|
|
86
|
+
|
|
87
|
+
logger.info(
|
|
88
|
+
`Started periodic sync every ${this.cloudSyncConfig.syncInterval}ms`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stop periodic synchronization
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* cloudService.stopPeriodicSync();
|
|
97
|
+
*/
|
|
98
|
+
stopPeriodicSync(): void {
|
|
99
|
+
if (this.syncIntervalId) {
|
|
100
|
+
window.clearInterval(this.syncIntervalId);
|
|
101
|
+
|
|
102
|
+
this.syncIntervalId = undefined;
|
|
103
|
+
|
|
104
|
+
logger.info('Stopped periodic sync');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Perform manual synchronization
|
|
110
|
+
* @returns Promise that resolves when sync is complete
|
|
111
|
+
* @throws Error if cloud sync is not enabled
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* await cloudService.sync();
|
|
115
|
+
*/
|
|
116
|
+
async sync(): Promise<void> {
|
|
117
|
+
if (!this.cloudSyncEnabled) {
|
|
118
|
+
throw new Error('Cloud sync is not enabled. Make sure cloudSync config is provided.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
logger.info('Starting manual sync...');
|
|
123
|
+
|
|
124
|
+
await (this.db as unknown as { cloud: { sync: () => Promise<void> } })
|
|
125
|
+
.cloud.sync();
|
|
126
|
+
|
|
127
|
+
logger.info('Sync completed successfully');
|
|
128
|
+
} catch (error) {
|
|
129
|
+
logger.error('Sync failed:', error);
|
|
130
|
+
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get current synchronization status
|
|
137
|
+
* @returns Object containing sync status information
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* const { enabled, lastSync, isOnline } = cloudService.getSyncStatus();
|
|
141
|
+
*/
|
|
142
|
+
getSyncStatus(): { enabled: boolean; lastSync?: Date; isOnline?: boolean } {
|
|
143
|
+
if (!this.cloudSyncEnabled) {
|
|
144
|
+
return { enabled: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cloudDb = this.db as unknown as {
|
|
148
|
+
cloud: {
|
|
149
|
+
lastSync?: number;
|
|
150
|
+
isOnline?: boolean;
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
enabled: true,
|
|
156
|
+
lastSync: cloudDb.cloud.lastSync
|
|
157
|
+
? new Date(cloudDb.cloud.lastSync)
|
|
158
|
+
: undefined,
|
|
159
|
+
isOnline: cloudDb.cloud.isOnline,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Enable cloud synchronization
|
|
165
|
+
* @param config - Cloud sync configuration
|
|
166
|
+
* @returns Promise that resolves when cloud sync is enabled
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* await cloudService.enableCloudSync({ databaseUrl: 'https://example.cloud' });
|
|
170
|
+
*/
|
|
171
|
+
async enableCloudSync(config: CloudSyncConfig): Promise<void> {
|
|
172
|
+
if (this.cloudSyncEnabled) {
|
|
173
|
+
logger.warn('Cloud sync is already enabled');
|
|
174
|
+
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await this.initializeCloudSync(config);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Disable cloud synchronization
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* cloudService.disableCloudSync();
|
|
186
|
+
*/
|
|
187
|
+
disableCloudSync(): void {
|
|
188
|
+
this.stopPeriodicSync();
|
|
189
|
+
this.cloudSyncEnabled = false;
|
|
190
|
+
this.cloudSyncConfig = undefined;
|
|
191
|
+
|
|
192
|
+
logger.info('Cloud sync disabled');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if cloud sync is currently enabled
|
|
197
|
+
* @returns True if cloud sync is enabled, false otherwise
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* if (cloudService.isCloudSyncEnabled()) {
|
|
201
|
+
* // ...
|
|
202
|
+
* }
|
|
203
|
+
*/
|
|
204
|
+
isCloudSyncEnabled(): boolean {
|
|
205
|
+
return this.cloudSyncEnabled;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get current cloud sync configuration
|
|
210
|
+
* @returns Cloud sync configuration or undefined if not set
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* const cfg = cloudService.getCloudSyncConfig();
|
|
214
|
+
*/
|
|
215
|
+
getCloudSyncConfig(): CloudSyncConfig | undefined {
|
|
216
|
+
return this.cloudSyncConfig;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Synchronize specific tables
|
|
221
|
+
* @param tableNames - Array of table names to sync
|
|
222
|
+
* @returns Promise that resolves when sync is complete
|
|
223
|
+
* @throws Error if cloud sync is not enabled
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* await cloudService.syncTables(['users', 'posts']);
|
|
227
|
+
*/
|
|
228
|
+
async syncTables(tableNames: string[]): Promise<void> {
|
|
229
|
+
if (!this.cloudSyncEnabled) {
|
|
230
|
+
throw new Error('Cloud sync is not enabled');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
logger.info(`Syncing tables: ${tableNames.join(', ')}`);
|
|
235
|
+
|
|
236
|
+
for (const tableName of tableNames) {
|
|
237
|
+
const table = this.db.table(tableName);
|
|
238
|
+
|
|
239
|
+
await table.toCollection().modify(() => {});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
logger.info('Table sync completed');
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger.error('Table sync failed:', error);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BaseEntity } from './BaseEntity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a new entity instance and validate it in a single call.
|
|
5
|
+
*
|
|
6
|
+
* This helper instantiates the provided entity class, assigns the given
|
|
7
|
+
* partial data, and immediately validates it using the entity's
|
|
8
|
+
* `init` method (which calls `validateOrThrow` under the hood).
|
|
9
|
+
*
|
|
10
|
+
* - Preferred construction style matches the project convention:
|
|
11
|
+
* empty constructor followed by property assignment and validation.
|
|
12
|
+
* - If the entity defines a Zod schema, invalid data will cause a
|
|
13
|
+
* ValidationError to be thrown.
|
|
14
|
+
*
|
|
15
|
+
* @typeParam T - Concrete entity type extending BaseEntity
|
|
16
|
+
* @param EntityClass - Entity constructor (must have a zero-arg constructor)
|
|
17
|
+
* @param data - Partial entity data to assign before validation
|
|
18
|
+
* @returns A fully initialized and validated entity instance
|
|
19
|
+
* @throws {import('../errors/ValidationError').ValidationError}
|
|
20
|
+
* If validation fails according to the entity schema
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const user = newEntity(User, { name: 'Alice' });
|
|
24
|
+
* // user is validated; throws if required fields are missing or invalid
|
|
25
|
+
*/
|
|
26
|
+
export function newEntity<T extends BaseEntity>(
|
|
27
|
+
EntityClass: new () => T,
|
|
28
|
+
data: Partial<T>,
|
|
29
|
+
): T {
|
|
30
|
+
const instance = new EntityClass();
|
|
31
|
+
|
|
32
|
+
return instance.init(data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|