@alexmitchell/melondb 0.0.0-stage → 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.
@@ -0,0 +1,298 @@
1
+ import { ComponentType } from "react";
2
+ import { Collection, Model, Q } from "@nozbe/watermelondb";
3
+ import { setGenerator } from "@nozbe/watermelondb/utils/common/randomId";
4
+ import { createWatermelon, WatermelonParams } from "./createWatermelon";
5
+ import { sanitizeCircular } from "./sanitizeCircular";
6
+
7
+ export type Melon = ReturnType<typeof createMelon>;
8
+
9
+ export type MelonParams = WatermelonParams & {
10
+ /** Console logging for failed lookups and database resets. Defaults to `true`. */
11
+ logging?: boolean;
12
+ /** Replaces WatermelonDB's global id generator. Defaults to WatermelonDB's own generator. */
13
+ idGenerator?: () => string;
14
+ };
15
+
16
+ export const createMelon = <
17
+ TableModels extends Record<string, Model>,
18
+ TableData extends Record<keyof TableModels, unknown>,
19
+ >(
20
+ params: MelonParams,
21
+ ) => {
22
+ type TableNames = Extract<keyof TableModels, string>;
23
+
24
+ const logging = params.logging ?? true;
25
+ if (params.idGenerator) {
26
+ setGenerator(params.idGenerator);
27
+ }
28
+
29
+ const watermelon = createWatermelon(params);
30
+
31
+ /**
32
+ * Enhance a component with observable data while keeping strong type safety.
33
+ *
34
+ * @template Injected - Props injected into the component by the enhancer.
35
+ * @template Query - Props required by the enhancer (used to trigger observables).
36
+ * @template Props - Additional props required by the component (not used by the enhancer).
37
+ */
38
+ const enhance = <Injected, Query = unknown, Props = unknown>(
39
+ enhancer: (
40
+ Component: ComponentType<Injected & Props>,
41
+ ) => ComponentType<Query & Props>,
42
+ Component: ComponentType<Injected & Props>,
43
+ ): ComponentType<Query & Props> => {
44
+ return enhancer(Component);
45
+ };
46
+
47
+ const get = <T extends TableNames>(tableName: T) => {
48
+ return watermelon.get(tableName) as unknown as Collection<TableModels[T]>;
49
+ };
50
+
51
+ const getRowById = async <T extends TableNames>(tableName: T, id: string) => {
52
+ try {
53
+ const rows = await get(tableName).query(Q.where("id", id)).fetch();
54
+ return rows[0] ?? null;
55
+ } catch {
56
+ if (logging) {
57
+ console.error("[🍈 MelonDB] Cannot find row:", tableName, id);
58
+ }
59
+ return null;
60
+ }
61
+ };
62
+
63
+ const insert = async <T extends TableNames>(
64
+ tableName: T,
65
+ data: TableData[T],
66
+ ): Promise<string> => {
67
+ return await watermelon.write(async () => {
68
+ const collection = get(tableName);
69
+ const record = await collection.create((record) => {
70
+ Object.assign(record, data);
71
+ });
72
+ return record.id;
73
+ });
74
+ };
75
+
76
+ const insertBatch = async <T extends TableNames>(
77
+ tableName: T,
78
+ data: TableData[T][],
79
+ ) => {
80
+ return await watermelon.write(async () => {
81
+ const collection = get(tableName);
82
+ const newRows = data.map((row) =>
83
+ collection.prepareCreate((record) => {
84
+ Object.assign(record, row);
85
+ }),
86
+ );
87
+ await watermelon.batch(newRows);
88
+ });
89
+ };
90
+
91
+ const updateRow = async <T extends TableNames>(
92
+ model: TableModels[T],
93
+ updater: (row: TableModels[T]) => void,
94
+ ) => {
95
+ return await watermelon.write(() => model.update(updater));
96
+ };
97
+
98
+ /**
99
+ * Update one column inside a write transaction.
100
+ * If the model has an `updatedDate` field, it is set to now (ISO string).
101
+ */
102
+ const updateColumn = <M extends Model, P extends keyof M, V extends M[P]>(
103
+ model: M,
104
+ property: P,
105
+ value: V,
106
+ ) => {
107
+ return model.database.write(() => {
108
+ return model.update((row) => {
109
+ row[property] = value;
110
+ if ("updatedDate" in row) {
111
+ row.updatedDate = new Date().toISOString();
112
+ }
113
+ });
114
+ });
115
+ };
116
+
117
+ /**
118
+ * Update several columns in one write transaction.
119
+ * If the model has an `updatedDate` field, it is set to now (ISO string).
120
+ */
121
+ const updateColumns = <M extends Model, P extends keyof M, V extends M[P]>(
122
+ model: M,
123
+ columns: { property: P; value: V }[],
124
+ ) => {
125
+ return model.database.write(() => {
126
+ return model.update((row) => {
127
+ columns.forEach(({ property, value }) => {
128
+ row[property] = value;
129
+ });
130
+ if ("updatedDate" in row) {
131
+ row.updatedDate = new Date().toISOString();
132
+ }
133
+ });
134
+ });
135
+ };
136
+
137
+ const deleteRow = async <T extends Model>(row: T) => {
138
+ await watermelon.write(async () => {
139
+ await row.markAsDeleted();
140
+ });
141
+ };
142
+
143
+ const deleteRowById = async <T extends TableNames>(
144
+ tableName: T,
145
+ id: string | undefined,
146
+ ) => {
147
+ if (!id) {
148
+ return;
149
+ }
150
+ const row = await getRowById(tableName, id);
151
+ if (!row) {
152
+ return;
153
+ }
154
+ return await deleteRow(row);
155
+ };
156
+
157
+ const deleteBatch = async <T extends Model>(rows: T[]) => {
158
+ if (rows.length === 0) {
159
+ return;
160
+ }
161
+
162
+ await rows[0].collection.database.write(async () => {
163
+ for (const row of rows) {
164
+ await row.markAsDeleted();
165
+ }
166
+ });
167
+ };
168
+
169
+ /**
170
+ * Soft-delete every row in a table (`markAsDeleted`) in one write transaction.
171
+ */
172
+ const deleteTable = async <T extends TableNames>(tableName: T) => {
173
+ await watermelon.write(async () => {
174
+ const collection = watermelon.collections.get(tableName);
175
+ const records = await collection.query().fetch();
176
+
177
+ if (records.length === 0) {
178
+ return;
179
+ }
180
+
181
+ for (const record of records) {
182
+ await record.markAsDeleted();
183
+ }
184
+ });
185
+ };
186
+
187
+ /**
188
+ * Permanently destroy every row in a table, in batched writes.
189
+ */
190
+ const emptyTable = async <T extends TableNames>(
191
+ tableName: T,
192
+ batchSize = 500,
193
+ ) => {
194
+ await watermelon.write(async () => {
195
+ const collection = watermelon.collections.get(tableName);
196
+ const records = await collection.query().fetch();
197
+
198
+ if (records.length === 0) {
199
+ return;
200
+ }
201
+
202
+ for (let i = 0; i < records.length; i += batchSize) {
203
+ const chunk = records.slice(i, i + batchSize);
204
+ const prepared = chunk.map((r) => r.prepareDestroyPermanently());
205
+ await watermelon.batch(prepared);
206
+ }
207
+ });
208
+ };
209
+
210
+ const getRowCount = async (tableName: TableNames) => {
211
+ return await get(tableName).query().fetchCount();
212
+ };
213
+
214
+ const logRowCount = async (tableName: TableNames) => {
215
+ const count = await getRowCount(tableName);
216
+ console.log(`[🍈 MelonDB] Total ${tableName} rows in DB: ${count}`);
217
+ };
218
+
219
+ const logAllRows = async (tableName: TableNames) => {
220
+ const rows = await get(tableName).query().fetch();
221
+ for (const row of rows) {
222
+ console.log(JSON.stringify(sanitizeCircular(row)));
223
+ }
224
+ };
225
+
226
+ // Observers must unmount before unsafeResetDatabase — see WatermelonDB #102
227
+ let databaseAvailable = true;
228
+ const databaseAvailableListeners = new Set<(available: boolean) => void>();
229
+
230
+ const getDatabaseAvailable = () => databaseAvailable;
231
+
232
+ const subscribeDatabaseAvailable = (
233
+ listener: (available: boolean) => void,
234
+ ) => {
235
+ databaseAvailableListeners.add(listener);
236
+ return () => {
237
+ databaseAvailableListeners.delete(listener);
238
+ };
239
+ };
240
+
241
+ const setDatabaseAvailable = (available: boolean) => {
242
+ if (databaseAvailable === available) {
243
+ return;
244
+ }
245
+ databaseAvailable = available;
246
+ databaseAvailableListeners.forEach((listener) => listener(available));
247
+ };
248
+
249
+ /**
250
+ * Reset the database safely: flip `databaseAvailable` to false first so UI
251
+ * that subscribes can unmount observers, then call `unsafeResetDatabase`.
252
+ */
253
+ const resetDatabase = async () => {
254
+ setDatabaseAvailable(false);
255
+ await new Promise<void>((resolve) => {
256
+ requestAnimationFrame(() => {
257
+ requestAnimationFrame(() => resolve());
258
+ });
259
+ });
260
+ // Yield a macrotask so React can finish unmounting observers before we wipe the DB.
261
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
262
+
263
+ try {
264
+ await watermelon.write(async () => {
265
+ await watermelon.unsafeResetDatabase();
266
+ if (logging) {
267
+ console.log("[🍈 MelonDB] Database reset");
268
+ }
269
+ });
270
+ } finally {
271
+ setDatabaseAvailable(true);
272
+ }
273
+ };
274
+
275
+ return {
276
+ /** Underlying WatermelonDB `Database` instance. */
277
+ db: watermelon,
278
+ enhance,
279
+ get,
280
+ getRowById,
281
+ insert,
282
+ insertBatch,
283
+ updateRow,
284
+ updateColumn,
285
+ updateColumns,
286
+ deleteRow,
287
+ deleteRowById,
288
+ deleteBatch,
289
+ deleteTable,
290
+ emptyTable,
291
+ getRowCount,
292
+ logRowCount,
293
+ logAllRows,
294
+ resetDatabase,
295
+ getDatabaseAvailable,
296
+ subscribeDatabaseAvailable,
297
+ };
298
+ };
@@ -0,0 +1,42 @@
1
+ import { appSchema, Database } from '@nozbe/watermelondb'
2
+ import { DatabaseProps } from '@nozbe/watermelondb/Database'
3
+ import { AppSchemaSpec } from '@nozbe/watermelondb/Schema'
4
+ import {
5
+ schemaMigrations,
6
+ SchemaMigrationsSpec,
7
+ } from '@nozbe/watermelondb/Schema/migrations'
8
+ import { createAdapter } from './createAdapter'
9
+
10
+ export type WatermelonParams = {
11
+ version: AppSchemaSpec['version']
12
+ tables: AppSchemaSpec['tables']
13
+ migrations: SchemaMigrationsSpec['migrations']
14
+ modelClasses: DatabaseProps['modelClasses']
15
+ }
16
+
17
+ /**
18
+ * Create a WatermelonDB `Database` with the usual schema + migrations setup,
19
+ * and a platform adapter (SQLite on native, LokiJS on web).
20
+ */
21
+ export const createWatermelon = ({
22
+ version,
23
+ tables,
24
+ migrations,
25
+ modelClasses,
26
+ }: WatermelonParams) => {
27
+ const schema = appSchema({
28
+ version,
29
+ tables,
30
+ })
31
+
32
+ const migrationConfig = schemaMigrations({
33
+ migrations,
34
+ })
35
+
36
+ const adapter = createAdapter({ schema, migrations: migrationConfig })
37
+
38
+ return new Database({
39
+ adapter,
40
+ modelClasses,
41
+ })
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { createMelon } from "./createMelon";
2
+ export type { Melon, MelonParams } from "./createMelon";
3
+
4
+ export { createWatermelon } from "./createWatermelon";
5
+ export type { WatermelonParams } from "./createWatermelon";
6
+
7
+ export { createAdapter } from "./createAdapter";
8
+
9
+ export { cleanRow } from "./cleanRow";
10
+ export type { DirtyRaw } from "./cleanRow";
11
+
12
+ export { sanitizeCircular } from "./sanitizeCircular";
@@ -0,0 +1,76 @@
1
+ import { Model } from '@nozbe/watermelondb'
2
+
3
+ /**
4
+ * Strip circular WatermelonDB model references so rows can be logged or
5
+ * JSON.stringified during debugging.
6
+ */
7
+ export const sanitizeCircular = (
8
+ obj: unknown,
9
+ seen = new WeakSet<object>()
10
+ ): unknown => {
11
+ if (obj === null || obj === undefined) {
12
+ return obj
13
+ }
14
+
15
+ if (typeof obj !== 'object') {
16
+ return obj
17
+ }
18
+
19
+ if (seen.has(obj as object)) {
20
+ return '[Circular Reference]'
21
+ }
22
+
23
+ if (obj instanceof Model) {
24
+ seen.add(obj)
25
+ const plain: Record<string, unknown> = {}
26
+
27
+ for (const key in obj) {
28
+ if (key !== '_raw' && key !== 'collection' && key !== 'database') {
29
+ try {
30
+ const value = (obj as unknown as Record<string, unknown>)[key]
31
+ if (typeof value !== 'function') {
32
+ plain[key] = sanitizeCircular(value, seen)
33
+ }
34
+ } catch {
35
+ // Skip properties that throw on access
36
+ }
37
+ }
38
+ }
39
+
40
+ if ('_raw' in obj && typeof (obj as { _raw?: unknown })._raw === 'object') {
41
+ const raw = (obj as { _raw: Record<string, unknown> })._raw
42
+ for (const key in raw) {
43
+ if (key !== '_status' && key !== '_changed' && key !== 'id') {
44
+ plain[key] = sanitizeCircular(raw[key], seen)
45
+ }
46
+ }
47
+ }
48
+
49
+ plain._modelType = obj.constructor.name
50
+ if ('id' in obj) {
51
+ plain.id = (obj as { id?: unknown }).id
52
+ }
53
+ return plain
54
+ }
55
+
56
+ if (Array.isArray(obj)) {
57
+ seen.add(obj)
58
+ return obj.map((item) => sanitizeCircular(item, seen))
59
+ }
60
+
61
+ seen.add(obj as object)
62
+ const sanitized: Record<string, unknown> = {}
63
+ for (const key in obj as Record<string, unknown>) {
64
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
65
+ try {
66
+ sanitized[key] = sanitizeCircular(
67
+ (obj as Record<string, unknown>)[key],
68
+ seen
69
+ )
70
+ } catch {
71
+ sanitized[key] = '[Error accessing property]'
72
+ }
73
+ }
74
+ }
75
+ return sanitized
76
+ }