@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.
Files changed (216) hide show
  1. package/.vscode/extensions.json +5 -0
  2. package/README.md +1280 -0
  3. package/angular-demo-app/README.md +84 -0
  4. package/angular-demo-app/angular.json +109 -0
  5. package/angular-demo-app/package-lock.json +14215 -0
  6. package/angular-demo-app/package.json +41 -0
  7. package/angular-demo-app/src/app/app.component.ts +481 -0
  8. package/angular-demo-app/src/app/app.routes.ts +8 -0
  9. package/angular-demo-app/src/app/components/actions.component.ts +202 -0
  10. package/angular-demo-app/src/app/components/cloud-sync-demo.component.ts +296 -0
  11. package/angular-demo-app/src/app/components/live-query-demo.component.ts +307 -0
  12. package/angular-demo-app/src/app/components/main-info.component.ts +148 -0
  13. package/angular-demo-app/src/app/components/posts-live-query-demo.component.ts +336 -0
  14. package/angular-demo-app/src/app/components/typescript-demo.component.ts +268 -0
  15. package/angular-demo-app/src/entities/post-tag.entity.ts +25 -0
  16. package/angular-demo-app/src/entities/post.entity.ts +49 -0
  17. package/angular-demo-app/src/entities/profile.entity.ts +42 -0
  18. package/angular-demo-app/src/entities/tag.entity.ts +36 -0
  19. package/angular-demo-app/src/entities/user.entity.ts +59 -0
  20. package/angular-demo-app/src/favicon.ico +1 -0
  21. package/angular-demo-app/src/index.html +16 -0
  22. package/angular-demo-app/src/main.ts +13 -0
  23. package/angular-demo-app/src/services/app-logic.service.ts +449 -0
  24. package/angular-demo-app/src/services/cloud-sync.service.ts +95 -0
  25. package/angular-demo-app/src/services/database.service.ts +26 -0
  26. package/angular-demo-app/src/services/live-query.service.ts +63 -0
  27. package/angular-demo-app/src/services/posts-live-query.service.ts +86 -0
  28. package/angular-demo-app/src/services/typescript-demo.service.ts +59 -0
  29. package/angular-demo-app/src/styles.scss +50 -0
  30. package/angular-demo-app/tsconfig.app.json +13 -0
  31. package/angular-demo-app/tsconfig.json +34 -0
  32. package/angular-demo-app/tsconfig.spec.json +13 -0
  33. package/dist/Database.d.ts +206 -0
  34. package/dist/Database.js +288 -0
  35. package/dist/decorators/Column.d.ts +79 -0
  36. package/dist/decorators/Column.js +236 -0
  37. package/dist/decorators/Entity.d.ts +32 -0
  38. package/dist/decorators/Entity.js +44 -0
  39. package/dist/decorators/Relation.d.ts +70 -0
  40. package/dist/decorators/Relation.js +120 -0
  41. package/dist/decorators/index.d.ts +3 -0
  42. package/dist/decorators/index.js +3 -0
  43. package/dist/errors/ValidationError.d.ts +4 -0
  44. package/dist/errors/ValidationError.js +8 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +7 -0
  47. package/dist/metadata/Column.d.ts +8 -0
  48. package/dist/metadata/Column.js +44 -0
  49. package/dist/metadata/Entity.d.ts +11 -0
  50. package/dist/metadata/Entity.js +21 -0
  51. package/dist/metadata/Relation.d.ts +20 -0
  52. package/dist/metadata/Relation.js +74 -0
  53. package/dist/metadata/index.d.ts +3 -0
  54. package/dist/metadata/index.js +3 -0
  55. package/dist/services/AggregationService.d.ts +38 -0
  56. package/dist/services/AggregationService.js +229 -0
  57. package/dist/services/BaseEntity.d.ts +32 -0
  58. package/dist/services/BaseEntity.js +62 -0
  59. package/dist/services/CloudSyncService.d.ts +100 -0
  60. package/dist/services/CloudSyncService.js +196 -0
  61. package/dist/services/DecoratorUtils.d.ts +12 -0
  62. package/dist/services/DecoratorUtils.js +10 -0
  63. package/dist/services/EntityFactory.d.ts +25 -0
  64. package/dist/services/EntityFactory.js +27 -0
  65. package/dist/services/EntityRegistry.d.ts +61 -0
  66. package/dist/services/EntityRegistry.js +56 -0
  67. package/dist/services/EntitySchema.d.ts +56 -0
  68. package/dist/services/EntitySchema.js +125 -0
  69. package/dist/services/MigrationManager.d.ts +70 -0
  70. package/dist/services/MigrationManager.js +181 -0
  71. package/dist/services/RelationLoader.d.ts +66 -0
  72. package/dist/services/RelationLoader.js +310 -0
  73. package/dist/services/SchemaBuilder.d.ts +68 -0
  74. package/dist/services/SchemaBuilder.js +191 -0
  75. package/dist/services/index.d.ts +7 -0
  76. package/dist/services/index.js +7 -0
  77. package/dist/types.d.ts +152 -0
  78. package/dist/types.js +1 -0
  79. package/dist/utils/logger.d.ts +12 -0
  80. package/dist/utils/logger.js +16 -0
  81. package/eslint.config.js +49 -0
  82. package/homepage/favicon.svg +36 -0
  83. package/homepage/index.html +1725 -0
  84. package/package.json +78 -0
  85. package/react-demo-app/README.md +61 -0
  86. package/react-demo-app/eslint.config.js +60 -0
  87. package/react-demo-app/index.html +13 -0
  88. package/react-demo-app/package-lock.json +4955 -0
  89. package/react-demo-app/package.json +39 -0
  90. package/react-demo-app/src/App.tsx +172 -0
  91. package/react-demo-app/src/assets/react.svg +1 -0
  92. package/react-demo-app/src/components/Actions.tsx +171 -0
  93. package/react-demo-app/src/components/CloudSyncDemo.tsx +191 -0
  94. package/react-demo-app/src/components/LiveQueryDemo.tsx +122 -0
  95. package/react-demo-app/src/components/MainInfo.tsx +75 -0
  96. package/react-demo-app/src/components/PostsLiveQueryDemo.tsx +185 -0
  97. package/react-demo-app/src/components/TypeScriptDemo.tsx +190 -0
  98. package/react-demo-app/src/database/Database.ts +30 -0
  99. package/react-demo-app/src/entities/Post.ts +48 -0
  100. package/react-demo-app/src/entities/PostTag.ts +26 -0
  101. package/react-demo-app/src/entities/Profile.ts +41 -0
  102. package/react-demo-app/src/entities/Tag.ts +35 -0
  103. package/react-demo-app/src/entities/User.ts +61 -0
  104. package/react-demo-app/src/hooks/useAppLogic.ts +565 -0
  105. package/react-demo-app/src/hooks/useCloudSyncDemo.ts +84 -0
  106. package/react-demo-app/src/hooks/useLiveQueryDemo.ts +68 -0
  107. package/react-demo-app/src/hooks/usePostsLiveQueryDemo.ts +64 -0
  108. package/react-demo-app/src/hooks/useTypeScriptDemo.ts +43 -0
  109. package/react-demo-app/src/index.css +26 -0
  110. package/react-demo-app/src/main.tsx +18 -0
  111. package/react-demo-app/src/migrations/001-add-user-email-index.ts +17 -0
  112. package/react-demo-app/src/migrations/002-add-post-category.ts +37 -0
  113. package/react-demo-app/src/migrations/index.ts +8 -0
  114. package/react-demo-app/src/vite-env.d.ts +1 -0
  115. package/react-demo-app/tsconfig.app.json +22 -0
  116. package/react-demo-app/tsconfig.json +6 -0
  117. package/react-demo-app/vite.config.ts +10 -0
  118. package/src/Database.ts +405 -0
  119. package/src/errors/ValidationError.ts +9 -0
  120. package/src/index.ts +13 -0
  121. package/src/metadata/Column.ts +74 -0
  122. package/src/metadata/Entity.ts +42 -0
  123. package/src/metadata/Relation.ts +121 -0
  124. package/src/metadata/index.ts +5 -0
  125. package/src/services/AggregationService.ts +348 -0
  126. package/src/services/BaseEntity.ts +77 -0
  127. package/src/services/CloudSyncService.ts +248 -0
  128. package/src/services/EntityFactory.ts +35 -0
  129. package/src/services/EntityRegistry.ts +109 -0
  130. package/src/services/EntitySchema.ts +154 -0
  131. package/src/services/MigrationManager.ts +276 -0
  132. package/src/services/RelationLoader.ts +532 -0
  133. package/src/services/SchemaBuilder.ts +237 -0
  134. package/src/services/index.ts +7 -0
  135. package/src/types.d.ts +1 -0
  136. package/src/types.ts +169 -0
  137. package/src/utils/logger.ts +40 -0
  138. package/svelte-demo-app/README.md +61 -0
  139. package/svelte-demo-app/package-lock.json +3000 -0
  140. package/svelte-demo-app/package.json +30 -0
  141. package/svelte-demo-app/src/app.d.ts +12 -0
  142. package/svelte-demo-app/src/app.html +13 -0
  143. package/svelte-demo-app/src/components/Actions.svelte +121 -0
  144. package/svelte-demo-app/src/components/CloudSyncDemo.svelte +333 -0
  145. package/svelte-demo-app/src/components/LiveQueryDemo.svelte +191 -0
  146. package/svelte-demo-app/src/components/MainInfo.svelte +133 -0
  147. package/svelte-demo-app/src/components/PostsLiveQueryDemo.svelte +330 -0
  148. package/svelte-demo-app/src/components/TypeScriptDemo.svelte +251 -0
  149. package/svelte-demo-app/src/database/Database.ts +29 -0
  150. package/svelte-demo-app/src/entities/Post.ts +46 -0
  151. package/svelte-demo-app/src/entities/PostTag.ts +22 -0
  152. package/svelte-demo-app/src/entities/Profile.ts +39 -0
  153. package/svelte-demo-app/src/entities/Tag.ts +33 -0
  154. package/svelte-demo-app/src/entities/User.ts +62 -0
  155. package/svelte-demo-app/src/lib/database/Database.ts +30 -0
  156. package/svelte-demo-app/src/lib/entities/Post.ts +47 -0
  157. package/svelte-demo-app/src/lib/entities/PostTag.ts +23 -0
  158. package/svelte-demo-app/src/lib/entities/Profile.ts +40 -0
  159. package/svelte-demo-app/src/lib/entities/Tag.ts +34 -0
  160. package/svelte-demo-app/src/lib/entities/User.ts +59 -0
  161. package/svelte-demo-app/src/lib/index.ts +7 -0
  162. package/svelte-demo-app/src/lib/migrations/001-add-user-email-index.ts +17 -0
  163. package/svelte-demo-app/src/lib/migrations/002-add-post-category.ts +37 -0
  164. package/svelte-demo-app/src/lib/migrations/index.ts +8 -0
  165. package/svelte-demo-app/src/migrations/001-add-user-email-index.ts +17 -0
  166. package/svelte-demo-app/src/migrations/002-add-post-category.ts +37 -0
  167. package/svelte-demo-app/src/migrations/index.ts +8 -0
  168. package/svelte-demo-app/src/routes/+layout.js +3 -0
  169. package/svelte-demo-app/src/routes/+layout.svelte +228 -0
  170. package/svelte-demo-app/src/routes/+page.js +3 -0
  171. package/svelte-demo-app/src/routes/+page.svelte +1305 -0
  172. package/svelte-demo-app/src/stores/appStore.js +603 -0
  173. package/svelte-demo-app/svelte.config.js +18 -0
  174. package/svelte-demo-app/tsconfig.json +14 -0
  175. package/svelte-demo-app/vite.config.ts +6 -0
  176. package/tests/aggregation.e2e.test.ts +87 -0
  177. package/tests/base-entity.e2e.test.ts +47 -0
  178. package/tests/database-api.e2e.test.ts +177 -0
  179. package/tests/decorators.e2e.test.ts +40 -0
  180. package/tests/entity-schema.e2e.test.ts +58 -0
  181. package/tests/relation-loader-table-names.test.ts +192 -0
  182. package/tests/relations.e2e.test.ts +178 -0
  183. package/tests/zod-runtime.e2e.test.ts +69 -0
  184. package/tsconfig.json +21 -0
  185. package/vitest.config.ts +21 -0
  186. package/vitest.setup.ts +27 -0
  187. package/vue-demo-app/README.md +61 -0
  188. package/vue-demo-app/index.html +13 -0
  189. package/vue-demo-app/package-lock.json +1537 -0
  190. package/vue-demo-app/package.json +27 -0
  191. package/vue-demo-app/src/App.vue +100 -0
  192. package/vue-demo-app/src/components/Actions.vue +135 -0
  193. package/vue-demo-app/src/components/CloudSyncDemo.vue +139 -0
  194. package/vue-demo-app/src/components/LiveQueryDemo.vue +122 -0
  195. package/vue-demo-app/src/components/MainInfo.vue +80 -0
  196. package/vue-demo-app/src/components/PostsLiveQueryDemo.vue +136 -0
  197. package/vue-demo-app/src/components/TypeScriptDemo.vue +133 -0
  198. package/vue-demo-app/src/database/Database.ts +29 -0
  199. package/vue-demo-app/src/entities/Post.ts +48 -0
  200. package/vue-demo-app/src/entities/PostTag.ts +24 -0
  201. package/vue-demo-app/src/entities/Profile.ts +41 -0
  202. package/vue-demo-app/src/entities/Tag.ts +35 -0
  203. package/vue-demo-app/src/entities/User.ts +61 -0
  204. package/vue-demo-app/src/main.ts +29 -0
  205. package/vue-demo-app/src/migrations/001-add-user-email-index.ts +23 -0
  206. package/vue-demo-app/src/migrations/002-add-post-category.ts +46 -0
  207. package/vue-demo-app/src/migrations/index.ts +14 -0
  208. package/vue-demo-app/src/services/useAppLogic.ts +565 -0
  209. package/vue-demo-app/src/services/useCloudSyncDemo.ts +84 -0
  210. package/vue-demo-app/src/services/useLiveQueryDemo.ts +82 -0
  211. package/vue-demo-app/src/services/usePostsLiveQueryDemo.ts +77 -0
  212. package/vue-demo-app/src/services/useTypeScriptDemo.ts +56 -0
  213. package/vue-demo-app/src/vite-env.d.ts +1 -0
  214. package/vue-demo-app/tsconfig.json +25 -0
  215. package/vue-demo-app/tsconfig.node.json +10 -0
  216. 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
+