@superbright/indexeddb-orm 0.1.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.
@@ -0,0 +1,293 @@
1
+ import { z } from "zod";
2
+ import { kvGet, kvSet } from "../storage";
3
+
4
+ // Property-related schemas
5
+ export const ViewedUnitSchema = z.object({
6
+ unitId: z.string(),
7
+ viewedDate: z.string(),
8
+ });
9
+
10
+ export const TourContactDataSchema = z.object({
11
+ timezone: z.string(),
12
+ favouriteUnits: z.array(z.string()).optional(),
13
+ preferences: z.record(z.unknown()),
14
+ });
15
+
16
+ export const PropertyDataSchema = z.object({
17
+ id: z.string(),
18
+ slug: z.string(),
19
+ favoritedUnits: z.array(z.string()),
20
+ tourContactedOn: z.string().nullable(),
21
+ viewedUnits: z.array(ViewedUnitSchema),
22
+ questionnaireResults: z.unknown().nullable().optional(), // Generic for IFilters
23
+ tourContactData: TourContactDataSchema.nullable().optional(),
24
+ });
25
+
26
+ export const PropertyStoreDataSchema = z.object({
27
+ data: z.record(PropertyDataSchema),
28
+ propertySlug: z.string().nullable(),
29
+ propertyId: z.string().nullable(),
30
+ hasPreviouslySearched: z.array(z.string()),
31
+ });
32
+
33
+ export type ViewedUnit = z.infer<typeof ViewedUnitSchema>;
34
+ export type TourContactData = z.infer<typeof TourContactDataSchema>;
35
+ export type PropertyData = z.infer<typeof PropertyDataSchema>;
36
+ export type PropertyStoreData = z.infer<typeof PropertyStoreDataSchema>;
37
+
38
+ // Default state
39
+ const defaultPropertyStoreData: PropertyStoreData = {
40
+ data: {},
41
+ propertySlug: null,
42
+ propertyId: null,
43
+ hasPreviouslySearched: [],
44
+ };
45
+
46
+ // Core property store API
47
+ export class PropertyStore {
48
+ private async getState(): Promise<PropertyStoreData> {
49
+ const state = await kvGet<PropertyStoreData>("property");
50
+ return state ?? defaultPropertyStoreData;
51
+ }
52
+
53
+ private async setState(updater: (state: PropertyStoreData) => PropertyStoreData): Promise<void> {
54
+ const currentState = await this.getState();
55
+ const newState = updater(currentState);
56
+ await kvSet("property", newState);
57
+ }
58
+
59
+ // Basic state operations
60
+ async setData(value: Record<string, PropertyData>): Promise<void> {
61
+ await this.setState(state => ({ ...state, data: value }));
62
+ }
63
+
64
+ async setPropertySlug(slug: string): Promise<void> {
65
+ await this.setState(state => ({ ...state, propertySlug: slug }));
66
+ }
67
+
68
+ async setPropertyId(id: string): Promise<void> {
69
+ await this.setState(state => ({ ...state, propertyId: id }));
70
+ }
71
+
72
+ async removeData(key: string): Promise<void> {
73
+ await this.setState(state => ({
74
+ ...state,
75
+ data: Object.entries(state.data)
76
+ .filter(([k]) => k !== key)
77
+ .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
78
+ }));
79
+ }
80
+
81
+ async clearData(): Promise<void> {
82
+ await this.setState(state => ({ ...state, data: {} }));
83
+ }
84
+
85
+ async setHasPreviouslySearched(slug: string): Promise<void> {
86
+ await this.setState(state => ({
87
+ ...state,
88
+ hasPreviouslySearched: Array.from(
89
+ new Set([...state.hasPreviouslySearched, slug])
90
+ ),
91
+ }));
92
+ }
93
+
94
+ // Property-specific operations
95
+ async setTourContactedOn(): Promise<void> {
96
+ await this.setState(state => {
97
+ const propertyId = state.propertyId;
98
+ if (!propertyId) return state;
99
+
100
+ const property = state.data[propertyId];
101
+ if (!property) return state;
102
+
103
+ return {
104
+ ...state,
105
+ data: {
106
+ ...state.data,
107
+ [propertyId]: {
108
+ ...property,
109
+ tourContactedOn: new Date().toISOString(),
110
+ },
111
+ },
112
+ };
113
+ });
114
+ }
115
+
116
+ async getTourContactedOn(): Promise<string | null> {
117
+ const state = await this.getState();
118
+ const propertyId = state.propertyId;
119
+ if (!propertyId) return null;
120
+
121
+ return state.data[propertyId]?.tourContactedOn ?? null;
122
+ }
123
+
124
+ async setQuestionnaireResults(results: unknown): Promise<void> {
125
+ await this.setState(state => {
126
+ const propertyId = state.propertyId;
127
+ if (!propertyId) return state;
128
+
129
+ const property = state.data[propertyId];
130
+ if (!property) return state;
131
+
132
+ return {
133
+ ...state,
134
+ data: {
135
+ ...state.data,
136
+ [propertyId]: {
137
+ ...property,
138
+ questionnaireResults: results,
139
+ },
140
+ },
141
+ };
142
+ });
143
+ }
144
+
145
+ async setTourContactData(data: TourContactData): Promise<void> {
146
+ await this.setState(state => {
147
+ const propertyId = state.propertyId;
148
+ if (!propertyId) return state;
149
+
150
+ const property = state.data[propertyId];
151
+ if (!property) return state;
152
+
153
+ return {
154
+ ...state,
155
+ data: {
156
+ ...state.data,
157
+ [propertyId]: {
158
+ ...property,
159
+ tourContactData: data,
160
+ },
161
+ },
162
+ };
163
+ });
164
+ }
165
+
166
+ async toggleFavorite(unitId: string): Promise<void> {
167
+ await this.setState(state => {
168
+ const propertyId = state.propertyId;
169
+ if (!propertyId) return state;
170
+
171
+ const property = state.data[propertyId];
172
+ if (!property) return state;
173
+
174
+ const isFavorited = property.favoritedUnits.includes(unitId);
175
+ const updatedFavoritedUnits = isFavorited
176
+ ? property.favoritedUnits.filter((id) => id !== unitId)
177
+ : [...property.favoritedUnits, unitId];
178
+
179
+ return {
180
+ ...state,
181
+ data: {
182
+ ...state.data,
183
+ [propertyId]: {
184
+ ...property,
185
+ favoritedUnits: updatedFavoritedUnits,
186
+ },
187
+ },
188
+ };
189
+ });
190
+ }
191
+
192
+ async markUnitAsViewed(unitId: string, slug: string): Promise<void> {
193
+ const today = new Date();
194
+ const formattedDate = `${String(today.getMonth() + 1).padStart(
195
+ 2,
196
+ "0"
197
+ )}/${String(today.getDate()).padStart(2, "0")}`;
198
+
199
+ await this.setState(state => {
200
+ const propertyId = state.propertyId;
201
+ if (!propertyId) return state;
202
+
203
+ const property = state.data[propertyId];
204
+ if (!property) return state;
205
+
206
+ const updatedViewedUnits = [
207
+ // Remove existing entry if it exists
208
+ ...property.viewedUnits.filter((u) => u.unitId !== unitId),
209
+ // Add updated one
210
+ {
211
+ unitId,
212
+ viewedDate: formattedDate,
213
+ },
214
+ ];
215
+
216
+ return {
217
+ ...state,
218
+ data: {
219
+ ...state.data,
220
+ [propertyId]: {
221
+ ...property,
222
+ viewedUnits: updatedViewedUnits,
223
+ },
224
+ },
225
+ };
226
+ });
227
+
228
+ // Note: This opens a new window - you might want to handle this in the consuming app
229
+ if (typeof window !== 'undefined') {
230
+ window.open(`//${slug}`, "_blank");
231
+ }
232
+ }
233
+
234
+ // Utility methods for getting specific data
235
+ async getUnitState(unitId: string): Promise<{
236
+ isFavorite: boolean;
237
+ viewedDate: string;
238
+ }> {
239
+ const state = await this.getState();
240
+ const property = state.propertyId ? state.data[state.propertyId] : null;
241
+
242
+ return {
243
+ isFavorite: property?.favoritedUnits.includes(unitId) ?? false,
244
+ viewedDate:
245
+ property?.viewedUnits.find((u) => u.unitId === unitId)?.viewedDate ?? "",
246
+ };
247
+ }
248
+
249
+ async getPropertyData(propertyId?: string): Promise<PropertyData | null> {
250
+ const state = await this.getState();
251
+ const id = propertyId ?? state.propertyId;
252
+ return id ? state.data[id] ?? null : null;
253
+ }
254
+
255
+ async getCurrentProperty(): Promise<PropertyData | null> {
256
+ const state = await this.getState();
257
+ return state.propertyId ? state.data[state.propertyId] ?? null : null;
258
+ }
259
+
260
+ async getFullState(): Promise<PropertyStoreData> {
261
+ return this.getState();
262
+ }
263
+
264
+ // Initialize property if it doesn't exist
265
+ async initializeProperty(propertyId: string, slug: string): Promise<void> {
266
+ await this.setState(state => {
267
+ if (state.data[propertyId]) {
268
+ return { ...state, propertyId, propertySlug: slug };
269
+ }
270
+
271
+ return {
272
+ ...state,
273
+ propertyId,
274
+ propertySlug: slug,
275
+ data: {
276
+ ...state.data,
277
+ [propertyId]: {
278
+ id: propertyId,
279
+ slug,
280
+ favoritedUnits: [],
281
+ tourContactedOn: null,
282
+ viewedUnits: [],
283
+ questionnaireResults: null,
284
+ tourContactData: null,
285
+ },
286
+ },
287
+ };
288
+ });
289
+ }
290
+ }
291
+
292
+ // Export singleton instance
293
+ export const propertyStore = new PropertyStore();
@@ -0,0 +1,66 @@
1
+ import { getDB } from "../db";
2
+ import { UserSchema, type User } from "../schema";
3
+
4
+ const USER_POINTER_KEY = "user";
5
+
6
+ export type IdGenerator = () => string;
7
+ export const defaultIdGenerator: IdGenerator = () =>
8
+ typeof globalThis.crypto?.randomUUID === "function"
9
+ ? globalThis.crypto.randomUUID()
10
+ : (() => {
11
+ const b =
12
+ globalThis.crypto?.getRandomValues?.(new Uint8Array(16)) ??
13
+ Uint8Array.from({ length: 16 }, () => (Math.random() * 256) | 0);
14
+ b[6] = (b[6] & 0x0f) | 0x40; // v4
15
+ b[8] = (b[8] & 0x3f) | 0x80; // variant
16
+ const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
17
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
18
+ })();
19
+
20
+ const getPointerUuid = (row: any) => row?.value?.useruuid ?? row?.value?.uuid;
21
+
22
+ export async function ensureUser(
23
+ gen: IdGenerator = defaultIdGenerator,
24
+ ): Promise<User> {
25
+ const db = await getDB();
26
+
27
+ // Fast path
28
+ const ptrUuid = getPointerUuid(await db.kv.get(USER_POINTER_KEY));
29
+ if (ptrUuid) {
30
+ const existing = await db.users.get(ptrUuid);
31
+ if (existing) return UserSchema.parse(existing);
32
+ }
33
+
34
+ // Race-safe creation
35
+ try {
36
+ return await db.transaction("rw", db.kv, db.users, async () => {
37
+ const insideUuid = getPointerUuid(await db.kv.get(USER_POINTER_KEY));
38
+ if (insideUuid) {
39
+ const existing = await db.users.get(insideUuid);
40
+ if (existing) return UserSchema.parse(existing);
41
+ }
42
+
43
+ const uuid = gen();
44
+ await db.kv.add({ key: USER_POINTER_KEY, value: { useruuid: uuid } }); // claim pointer
45
+ const newUser: User = UserSchema.parse({
46
+ uuid,
47
+ createdAt: new Date().toISOString(),
48
+ });
49
+ await db.users.add(newUser);
50
+ return newUser;
51
+ });
52
+ } catch (e: any) {
53
+ if (e?.name === "ConstraintError") {
54
+ // Lost the race → read winner
55
+ const uuid = getPointerUuid(await db.kv.get(USER_POINTER_KEY));
56
+ if (uuid) {
57
+ const winner = await db.users.get(uuid);
58
+ if (winner) return UserSchema.parse(winner);
59
+ }
60
+ }
61
+ throw e;
62
+ }
63
+ }
64
+
65
+ export const getUserUUID = async (gen?: IdGenerator) =>
66
+ (await ensureUser(gen)).uuid;
package/src/db.ts ADDED
@@ -0,0 +1,185 @@
1
+ // src/db.ts
2
+ import { OrmDexie } from "./adapters/dexie";
3
+ import { OpenDBError } from "./errors";
4
+ import { SCHEMA_VERSION, UserSchema } from "./schema";
5
+ import {
6
+ validate,
7
+ configureValidation,
8
+ type ValidationMode,
9
+ } from "./validation";
10
+ import type { Table } from "dexie";
11
+ import { z } from "zod";
12
+
13
+ const MANIFEST_KEY = "manifest";
14
+ const ManifestSchema = z.object({ schemaVersion: z.number().int() });
15
+ type Manifest = z.infer<typeof ManifestSchema>;
16
+
17
+ export type OrmOptions = {
18
+ dbName?: string;
19
+ onReset?: (reason: "incompatible" | "versionchange" | "blocked") => void;
20
+ onError?: (err: unknown) => void;
21
+ validation?: {
22
+ mode?: ValidationMode; // 'strict' | 'warn' | 'silent'
23
+ onIssue?: (ctx: string, details: unknown) => void;
24
+ validateReads?: boolean; // default: true
25
+ dropInvalidOnRead?: boolean; // default: true (when validateReads)
26
+ };
27
+ };
28
+
29
+ let db: OrmDexie | null = null;
30
+ let openPromise: Promise<OrmDexie> | null = null;
31
+
32
+ // Prevent double-installing hooks on the same instance
33
+ const VALIDATION_INSTALLED = Symbol("validationInstalled");
34
+
35
+ function installValidationHooks(
36
+ instance: OrmDexie,
37
+ vopts: OrmOptions["validation"] | undefined,
38
+ ) {
39
+ const anyDb = instance as any;
40
+ if (anyDb[VALIDATION_INSTALLED]) return;
41
+ anyDb[VALIDATION_INSTALLED] = true;
42
+
43
+ const writeHook = <T>(table: Table<T, any>, schema: any, name: string) => {
44
+ table.hook("creating", (_pk, obj) => {
45
+ const v = validate(schema, obj, `${name}.creating`);
46
+ if (!v) throw new Error(`Rejected invalid ${name} on create`);
47
+ return v as T; // allow coercion/stripping
48
+ });
49
+ table.hook("updating", (mods, _pk, obj) => {
50
+ const next = { ...(obj as any), ...(mods as any) };
51
+ const v = validate(schema, next, `${name}.updating`);
52
+ if (!v) return {}; // cancel update in warn/silent
53
+ return mods;
54
+ });
55
+ };
56
+
57
+ writeHook(instance.users, UserSchema, "users");
58
+
59
+ const shouldValidateReads = vopts?.validateReads ?? true;
60
+ if (shouldValidateReads) {
61
+ const dropInvalid = vopts?.dropInvalidOnRead ?? true;
62
+ const readHook = <T>(table: Table<T, any>, schema: any, name: string) => {
63
+ table.hook("reading", (obj) => {
64
+ const v = validate(schema, obj, `${name}.reading`);
65
+ if (v) return v;
66
+ return dropInvalid ? undefined : obj; // pass-through if you prefer
67
+ });
68
+ };
69
+ readHook(instance.users, UserSchema, "users");
70
+ }
71
+ }
72
+
73
+ function isProbablyDev(): boolean {
74
+ try {
75
+ // @ts-ignore
76
+ if (typeof import.meta !== "undefined" && import.meta.env)
77
+ // @ts-ignore
78
+ return !!import.meta.env.DEV;
79
+ } catch {}
80
+ try {
81
+ if (typeof process !== "undefined" && process.env)
82
+ return process.env.NODE_ENV !== "production";
83
+ } catch {}
84
+ return false;
85
+ }
86
+
87
+ export async function getDB(opts: OrmOptions = {}): Promise<OrmDexie> {
88
+ if (db) return db;
89
+ if (openPromise) return openPromise;
90
+
91
+ const name = opts.dbName ?? "inresi-orm";
92
+
93
+ openPromise = (async () => {
94
+ try {
95
+ // Configure validation once per open
96
+ const defaultMode: ValidationMode = isProbablyDev() ? "warn" : "strict";
97
+ configureValidation({
98
+ mode: opts.validation?.mode ?? defaultMode,
99
+ onIssue: opts.validation?.onIssue,
100
+ });
101
+
102
+ const instance = new OrmDexie(name);
103
+
104
+ // Multi-tab friendliness
105
+ instance.on("versionchange", () => {
106
+ try {
107
+ instance.close();
108
+ } finally {
109
+ opts.onReset?.("versionchange");
110
+ }
111
+ });
112
+ instance.on("blocked", () => {
113
+ opts.onReset?.("blocked");
114
+ });
115
+
116
+ await instance.open();
117
+
118
+ // Validate/initialize manifest
119
+ const row = await instance.kv.get(MANIFEST_KEY);
120
+ const saved = (row?.value ?? null) as unknown;
121
+
122
+ if (!saved) {
123
+ await instance.transaction("rw", instance.kv, async () => {
124
+ await instance.kv.put({
125
+ key: MANIFEST_KEY,
126
+ value: { schemaVersion: SCHEMA_VERSION },
127
+ });
128
+ });
129
+ installValidationHooks(instance, opts.validation);
130
+ db = instance;
131
+ return instance;
132
+ }
133
+
134
+ const parsed = ManifestSchema.safeParse(saved as Manifest);
135
+ if (!parsed.success || parsed.data.schemaVersion !== SCHEMA_VERSION) {
136
+ await instance.delete();
137
+ const fresh = new OrmDexie(name);
138
+ fresh.on("versionchange", () => {
139
+ try {
140
+ fresh.close();
141
+ } finally {
142
+ opts.onReset?.("versionchange");
143
+ }
144
+ });
145
+ fresh.on("blocked", () => {
146
+ opts.onReset?.("blocked");
147
+ });
148
+ await fresh.open();
149
+ await fresh.kv.put({
150
+ key: MANIFEST_KEY,
151
+ value: { schemaVersion: SCHEMA_VERSION },
152
+ });
153
+ installValidationHooks(fresh, opts.validation);
154
+ opts.onReset?.("incompatible");
155
+ db = fresh;
156
+ return fresh;
157
+ }
158
+
159
+ installValidationHooks(instance, opts.validation);
160
+ db = instance;
161
+ return instance;
162
+ } catch (e) {
163
+ opts.onError?.(e);
164
+ throw new OpenDBError("Failed to open IndexedDB", e);
165
+ } finally {
166
+ openPromise = null; // allow future reuse/retry
167
+ }
168
+ })();
169
+
170
+ return openPromise;
171
+ }
172
+
173
+ export async function resetDB(dbName?: string) {
174
+ const name = dbName ?? "inresi-orm";
175
+ if (db) {
176
+ try {
177
+ await db.close();
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ }
182
+ const instance = new OrmDexie(name);
183
+ await instance.delete();
184
+ db = null;
185
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { getDB } from "./db";
2
+
3
+ export async function debugDump(): Promise<Record<string, unknown[]>> {
4
+ const db = await getDB();
5
+ const out: Record<string, unknown[]> = {};
6
+ for (const t of db.tables) {
7
+ out[t.name] = await t.toArray();
8
+ }
9
+ return out;
10
+ }
11
+
12
+ export async function exportJSON(filename = "inresi-orm-export.json"): Promise<void> {
13
+ if (typeof window === "undefined" || typeof document === "undefined") {
14
+ throw new Error("exportJSON can only run in a browser.");
15
+ }
16
+ const snapshot = await debugDump();
17
+ const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
18
+ const a = document.createElement("a");
19
+ a.href = URL.createObjectURL(blob);
20
+ a.download = filename;
21
+ document.body.appendChild(a);
22
+ a.click();
23
+ a.remove();
24
+ URL.revokeObjectURL(a.href);
25
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,13 @@
1
+ export class SchemaMismatchError extends Error {
2
+ constructor(message: string, public detail?: unknown) {
3
+ super(message);
4
+ this.name = "SchemaMismatchError";
5
+ }
6
+ }
7
+
8
+ export class OpenDBError extends Error {
9
+ constructor(message: string, public detail?: unknown) {
10
+ super(message);
11
+ this.name = "OpenDBError";
12
+ }
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ export * from "./errors";
2
+ export * from "./schema";
3
+ export * from "./db";
4
+ export * from "./api/users";
5
+ export * from "./debug";
6
+ export * from "./storage";
7
+ export * from "./api/favorites";
8
+ export {
9
+ PropertyStore,
10
+ propertyStore,
11
+ type PropertyData,
12
+ type PropertyStoreData,
13
+ } from "./api/properties";
14
+
15
+ // Export unified store classes and main functionality
16
+ export { UnifiedStore, store } from "./stores/unified";
17
+ export {
18
+ createZustandUnifiedStore,
19
+ createZustandUnifiedStore as createZustandPropertyStore, // Alias for easier migration
20
+ createUseUnitState,
21
+ type ZustandUnifiedStoreState
22
+ } from "./adapters/zustand-unified";
23
+
24
+ // Export structured store with nested actions
25
+ export {
26
+ createStructuredStore,
27
+ createStructuredStoreActions,
28
+ type StructuredStore,
29
+ type StructuredStoreActions
30
+ } from "./adapters/structured-store";
31
+
32
+ export { favorites } from "./units/favorites";
33
+
34
+ export { configureValidation, type ValidationMode } from "./validation";
package/src/schema.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+
3
+ export const SCHEMA_VERSION = 1 as const;
4
+
5
+ // Entities
6
+ export const UserSchema = z.object({
7
+ uuid: z.string().uuid(),
8
+ });
9
+ export type User = z.infer<typeof UserSchema>;
10
+
11
+ // favorites
12
+ export const FavoritesSchema = z.object({
13
+ propertyId: z.string() || z.number(),
14
+ unitIds: z.array(z.string()),
15
+ });
16
+ export type Favorites = z.infer<typeof FavoritesSchema>;
17
+
18
+ // Re-export property schemas from the API module
19
+ export {
20
+ ViewedUnitSchema,
21
+ TourContactDataSchema,
22
+ PropertyDataSchema,
23
+ PropertyStoreDataSchema,
24
+ type ViewedUnit,
25
+ type TourContactData,
26
+ type PropertyData,
27
+ type PropertyStoreData,
28
+ } from "./api/properties";
29
+
30
+ // Re-export app schemas from the API module
31
+ export {
32
+ UnitDataSchema,
33
+ FiltersSchema,
34
+ QueryParamsSchema,
35
+ AppStoreDataSchema,
36
+ type UnitData,
37
+ type Filters,
38
+ type QueryParams,
39
+ type AppStoreData,
40
+ type ResultsMode,
41
+ type SortBy,
42
+ } from "./api/app";
43
+
44
+ // Re-export unified store schemas
45
+ export {
46
+ UnifiedStoreDataSchema,
47
+ type UnifiedStoreData,
48
+ } from "./stores/unified";