Merge pull request 'Migrate OSM cache to localForage/IndexedDB' (#76) from feature/indexeddb_cache into master
Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 11
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 11
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
+39
-4
@@ -1,6 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
// Detects whether the fresh OSM data differs from the currently-shown model
|
||||
// by more than ~1m of coordinate drift, or in any tag. Mirrors the relevant
|
||||
// subset of `storage.refreshPlace`'s diff for the non-bookmark path.
|
||||
function hasOsmChanges(place, fresh) {
|
||||
const latDiff = Math.abs((place.lat ?? 0) - (fresh.lat ?? 0));
|
||||
const lonDiff = Math.abs((place.lon ?? 0) - (fresh.lon ?? 0));
|
||||
if (latDiff > 0.00001 || lonDiff > 0.00001) return true;
|
||||
|
||||
const oldTags = place.osmTags || {};
|
||||
const newTags = fresh.osmTags || {};
|
||||
const allKeys = new Set([...Object.keys(oldTags), ...Object.keys(newTags)]);
|
||||
for (const key of allKeys) {
|
||||
if (oldTags[key] !== newTags[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class PlaceRoute extends Route {
|
||||
@service storage;
|
||||
@service osm;
|
||||
@@ -131,14 +148,32 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
if (!place || !place.osmId || !place.osmType) return;
|
||||
|
||||
// Bookmarked place — refresh via storage, which persists the update.
|
||||
if (place.id) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
// If an update occurred, refresh the map UI selection without moving
|
||||
// the camera. This ensures the sidebar shows the new data.
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-bookmarked explicit OSM place — fetch fresh and update the model
|
||||
// in-place if anything changed, so the sidebar reflects the latest OSM
|
||||
// data without requiring a re-open.
|
||||
try {
|
||||
const fresh = await this.osm.fetchOsmObject(place.osmId, place.osmType, {
|
||||
forceFresh: true,
|
||||
});
|
||||
if (fresh && hasOsmChanges(place, fresh)) {
|
||||
Object.assign(place, fresh);
|
||||
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('[place] Fresh fetch failed for', place.osmId, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { groupPhotoContributions } from '../utils/contributions';
|
||||
|
||||
const NAME_CACHE_KEY = 'marco:contributions:name_cache';
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||
@@ -11,54 +11,90 @@ const NAME_CACHE_KEY = 'marco:contributions:name_cache';
|
||||
* The flow is:
|
||||
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
||||
* 2. Group events into contribution entries via `groupPhotoContributions`.
|
||||
* 3. Resolve place names immediately from bookmarks / name cache / OSM cache.
|
||||
* 3. Resolve place names from bookmarks (sync, instant) on the first render,
|
||||
* then asynchronously from the IndexedDB name cache and OSM cache.
|
||||
* 4. For unresolved names, batch-fetch from the OSM API in the background.
|
||||
* 5. Any items that can't be resolved get a fallback name so they don't stay
|
||||
* stuck in a "Loading…" state forever.
|
||||
* 6. Update `@tracked items` so the UI renders progressively.
|
||||
*
|
||||
* The persistent name cache is stored in IndexedDB (via the `localForage`
|
||||
* service) with one key per `placeIdentifier`, so partial updates don't
|
||||
* require re-serializing the whole map.
|
||||
*/
|
||||
export default class ContributionsService extends Service {
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service storage;
|
||||
@service osm;
|
||||
@service localForage;
|
||||
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
_pendingBatchPromise = null;
|
||||
_lastBatchSignature = '';
|
||||
_nameCache = new Map();
|
||||
_unresolvable = new Set();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this._loadNameCache();
|
||||
/**
|
||||
* Async name resolution. Checks, in order:
|
||||
* 1. Bookmarks (sync, instant).
|
||||
* 2. The persistent IndexedDB name cache (per-entry key).
|
||||
* 3. The OSM service's IndexedDB cache (from place detail visits).
|
||||
*
|
||||
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async _resolveCachedName(entry) {
|
||||
// 1. Try bookmarks (instant)
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) return bookmark.title;
|
||||
|
||||
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
|
||||
const cachedName = await this.localForage.get(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier
|
||||
);
|
||||
if (cachedName) return cachedName;
|
||||
|
||||
// 3. Try OSM IndexedDB cache (from place detail visits)
|
||||
const cached = await this.osm.getCachedOsmObject(
|
||||
entry.osmType,
|
||||
entry.osmId
|
||||
);
|
||||
return cached?.title || null;
|
||||
}
|
||||
|
||||
_loadNameCache() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const raw = localStorage.getItem(NAME_CACHE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj && typeof obj === 'object') {
|
||||
this._nameCache = new Map(Object.entries(obj));
|
||||
/**
|
||||
* Asynchronously resolves names for still-loading entries from the caches.
|
||||
* Falls through to `_maybeBatchFetchNames` for anything that remains
|
||||
* unresolved. Fire-and-forget from `_updateItems` so the list renders
|
||||
* immediately with bookmark-resolved names.
|
||||
*
|
||||
* @param {Array} entries
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _resolveFromCache(entries) {
|
||||
const pending = entries.filter((e) => e.placeNameLoading);
|
||||
if (pending.length === 0) {
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
const name = await this._resolveCachedName(entry);
|
||||
if (name) {
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed cache
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_saveNameCache() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const obj = Object.fromEntries(this._nameCache);
|
||||
localStorage.setItem(NAME_CACHE_KEY, JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
console.debug('[contributions] Failed to persist name cache', e);
|
||||
}
|
||||
// Re-render with whatever resolved, then kick off the network batch for
|
||||
// entries that are still loading.
|
||||
this.items = [...this.items];
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,36 +145,21 @@ export default class ContributionsService extends Service {
|
||||
// 1. Group events into contribution entries (newest-first)
|
||||
const entries = groupPhotoContributions(events);
|
||||
|
||||
// 2. Resolve place names: preserve previously-resolved names, then check
|
||||
// bookmarks, the name cache, and the OSM service cache.
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
const cached = this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
entry.placeName = cached;
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) {
|
||||
entry.placeName = bookmark.title;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
|
||||
// 3. Trigger a background batch fetch for any still-unresolved names.
|
||||
// De-duplicate so we don't re-fetch the same set while a fetch is in-flight.
|
||||
this._maybeBatchFetchNames(entries);
|
||||
}
|
||||
|
||||
_resolveCachedName(entry) {
|
||||
// 1. Try bookmarks (instant)
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) return bookmark.title;
|
||||
|
||||
// 2. Try the persistent name cache (instant, survives across sessions)
|
||||
if (this._nameCache.has(entry.placeIdentifier)) {
|
||||
return this._nameCache.get(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
// 3. Try OSM localStorage cache (instant, from place detail visits)
|
||||
const cached = this.osm.getCachedOsmObject(entry.osmType, entry.osmId);
|
||||
return cached?.title || null;
|
||||
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||
// cache, then fall through to the network batch for the rest.
|
||||
void this._resolveFromCache(entries);
|
||||
}
|
||||
|
||||
_isUnresolvable(entry) {
|
||||
@@ -177,7 +198,7 @@ export default class ContributionsService extends Service {
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the current `items` and the name cache.
|
||||
// Merge resolved names back into the current `items`.
|
||||
for (const item of this.items) {
|
||||
if (!item.placeNameLoading) continue;
|
||||
if (nameMap.has(item.placeIdentifier)) {
|
||||
@@ -192,7 +213,6 @@ export default class ContributionsService extends Service {
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
// Trigger a re-render
|
||||
this.items = [...this.items];
|
||||
})
|
||||
@@ -206,7 +226,6 @@ export default class ContributionsService extends Service {
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -224,7 +243,7 @@ export default class ContributionsService extends Service {
|
||||
|
||||
// Re-check cache in case it was populated between the trigger and now
|
||||
for (const entry of entries) {
|
||||
const cached = this._resolveCachedName(entry);
|
||||
const cached = await this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
nameMap.set(entry.placeIdentifier, cached);
|
||||
} else {
|
||||
@@ -235,16 +254,26 @@ export default class ContributionsService extends Service {
|
||||
if (toFetch.length === 0) return nameMap;
|
||||
|
||||
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
||||
|
||||
// Persist each newly-resolved name to the IndexedDB name cache as its own
|
||||
// entry (per-placeIdentifier key) so we don't re-fetch next session.
|
||||
const writePromises = [];
|
||||
for (const entry of entries) {
|
||||
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||
const place = places.get(cacheKey);
|
||||
if (place?.title) {
|
||||
nameMap.set(entry.placeIdentifier, place.title);
|
||||
this._nameCache.set(entry.placeIdentifier, place.title);
|
||||
writePromises.push(
|
||||
this.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier,
|
||||
place.title
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
await Promise.all(writePromises);
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import localforage from 'localforage';
|
||||
import Service from '@ember/service';
|
||||
|
||||
const DB_NAME = 'marco';
|
||||
|
||||
/**
|
||||
* Thin async-only wrapper around `localforage` that exposes namespaced
|
||||
* object stores under a single IndexedDB database (`marco`).
|
||||
*
|
||||
* Each "store" is a `localforage` instance created with a unique
|
||||
* `storeName`, so callers can keep data isolated (e.g. OSM cache vs.
|
||||
* contributions name cache) without managing their own connections.
|
||||
*
|
||||
* All methods return Promises — IndexedDB is inherently async, so callers
|
||||
* must `await` every read/write.
|
||||
*/
|
||||
export default class LocalForageService extends Service {
|
||||
_instances = new Map();
|
||||
|
||||
_instance(storeName) {
|
||||
let instance = this._instances.get(storeName);
|
||||
if (!instance) {
|
||||
instance = localforage.createInstance({
|
||||
name: DB_NAME,
|
||||
storeName,
|
||||
});
|
||||
this._instances.set(storeName, instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
return this._instance(storeName).getItem(key);
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
return this._instance(storeName).setItem(key, value);
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
return this._instance(storeName).removeItem(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return this._instance(storeName).keys();
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
return this._instance(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
return this._instance(storeName).iterate(fn);
|
||||
}
|
||||
}
|
||||
+45
-35
@@ -4,16 +4,18 @@ import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class OsmService extends Service {
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
cachedPlaces = new Map();
|
||||
|
||||
// Long-term cache for OSM place metadata, persisted to localStorage so that
|
||||
// names and basic info survive across sessions and can be rendered instantly
|
||||
// without waiting on the OSM API. Entries are refreshed in the background.
|
||||
static CACHE_KEY_PREFIX = 'marco:osm_cache:';
|
||||
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
|
||||
// `localForage` service) so that names and basic info survive across
|
||||
// sessions and can be rendered instantly without waiting on the OSM API.
|
||||
// Entries are refreshed in the background.
|
||||
static STORE_NAME = 'osm-cache';
|
||||
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
||||
|
||||
@@ -21,27 +23,24 @@ export default class OsmService extends Service {
|
||||
return `${osmType}:${osmId}`;
|
||||
}
|
||||
|
||||
_readLocalCache(osmType, osmId) {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
||||
async _readLocalCache(osmType, osmId) {
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.timestamp !== 'number' ||
|
||||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||
) {
|
||||
localStorage.removeItem(key);
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
try {
|
||||
localStorage.removeItem(
|
||||
`${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`
|
||||
);
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -49,31 +48,32 @@ export default class OsmService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
_writeLocalCache(osmType, osmId, data) {
|
||||
if (typeof localStorage === 'undefined' || !data) return;
|
||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
||||
async _writeLocalCache(osmType, osmId, data) {
|
||||
if (!data) return;
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ data, timestamp: Date.now() })
|
||||
);
|
||||
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[osm] Failed to write localStorage cache entry', e);
|
||||
console.debug('[osm] Failed to write IndexedDB cache entry', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous lookup for an OSM place. Checks the short-lived in-memory cache
|
||||
* first, then the persistent localStorage cache. Returns `null` if not cached.
|
||||
* Async lookup for an OSM place. Checks the short-lived in-memory cache
|
||||
* first, then the persistent IndexedDB cache. Returns `null` if not
|
||||
* cached.
|
||||
*
|
||||
* Use this for instant rendering (e.g. place names in a list) and fall back to
|
||||
* `fetchOsmObject` for a fresh fetch + background refresh.
|
||||
* Use this for instant rendering (e.g. place names in a list) and fall
|
||||
* back to `fetchOsmObject` for a fresh fetch + background refresh.
|
||||
*
|
||||
* @param {string} osmType 'node' | 'way' | 'relation'
|
||||
* @param {string} osmId
|
||||
* @returns {object|null} Normalized OSM place data
|
||||
* @returns {Promise<object|null>} Normalized OSM place data
|
||||
*/
|
||||
getCachedOsmObject(osmType, osmId) {
|
||||
async getCachedOsmObject(osmType, osmId) {
|
||||
if (!osmType || !osmId) return null;
|
||||
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
@@ -103,7 +103,9 @@ export default class OsmService extends Service {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, OsmService.IN_MEMORY_TTL_MS);
|
||||
|
||||
this._writeLocalCache(osmType, osmId, data);
|
||||
// Fire-and-forget the persistent write — the in-memory cache covers the
|
||||
// next immediate read, and the IndexedDB write happens in the background.
|
||||
void this._writeLocalCache(osmType, osmId, data);
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
@@ -326,22 +328,30 @@ out center;
|
||||
return this.normalizePoi(data.elements[0]);
|
||||
}
|
||||
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
|
||||
// Force a fresh fetch from the API, bypassing the cache. Used by callers
|
||||
// that need genuinely current data (e.g. storage.refreshPlace, which
|
||||
// diffs the bookmark against freshly-fetched OSM data).
|
||||
if (forceFresh) {
|
||||
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||
}
|
||||
|
||||
// Cached-first path: return the in-memory entry if it's still warm.
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// If we have a persistent (localStorage) cache entry, return it immediately and
|
||||
// kick off a background refresh. This keeps the UI snappy while still ensuring
|
||||
// the cache is updated with the latest OSM data.
|
||||
const localCached = this._readLocalCache(osmType, osmId);
|
||||
// Otherwise return the persistent IndexedDB entry and refresh in the
|
||||
// background so the next visit is fresh.
|
||||
const localCached = await this._readLocalCache(osmType, osmId);
|
||||
if (localCached) {
|
||||
console.debug(`Using localStorage cached OSM object for ${cacheKey}`);
|
||||
console.debug(`Using IndexedDB cached OSM object for ${cacheKey}`);
|
||||
// Refresh in the background, but don't block the caller.
|
||||
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
||||
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
||||
@@ -415,7 +425,7 @@ out center;
|
||||
for (const { osmType, osmId } of items) {
|
||||
if (!osmType || !osmId) continue;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = this.getCachedOsmObject(osmType, osmId);
|
||||
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||
if (cached) {
|
||||
result.set(cacheKey, cached);
|
||||
} else {
|
||||
|
||||
@@ -426,7 +426,8 @@ export default class StorageService extends Service {
|
||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||
const freshData = await this.osm.fetchOsmObject(
|
||||
place.osmId,
|
||||
place.osmType
|
||||
place.osmType,
|
||||
{ forceFresh: true }
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"blurhash": "^2.0.5",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.1.0",
|
||||
"localforage": "^1.10.0",
|
||||
"nostr-idb": "^5.1.0",
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
Generated
+1117
-1094
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ class MockOsmService extends Service {
|
||||
}
|
||||
|
||||
getCachedOsmObject() {
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
import { setupLocalForageMock } from './mock-local-forage';
|
||||
import sinon from 'sinon';
|
||||
|
||||
function setupMapStyleMocks(hooks) {
|
||||
@@ -98,6 +99,7 @@ function setupMapStyleMocks(hooks) {
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
@@ -119,6 +121,7 @@ function setupApplicationTest(hooks, options) {
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
@@ -126,6 +129,7 @@ function setupRenderingTest(hooks, options) {
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Service from '@ember/service';
|
||||
|
||||
/**
|
||||
* In-memory mock of the `localForage` service for tests. Uses per-store
|
||||
* `Map`s so tests stay isolated from real IndexedDB state and stay
|
||||
* deterministic.
|
||||
*
|
||||
* Mirrors the real service's interface: `get/set/remove/keys/clear/iterate`.
|
||||
*/
|
||||
export class MockLocalForageService extends Service {
|
||||
_stores = new Map();
|
||||
|
||||
_store(name) {
|
||||
let store = this._stores.get(name);
|
||||
if (!store) {
|
||||
store = new Map();
|
||||
this._stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
const store = this._store(storeName);
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
this._store(storeName).set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
this._store(storeName).delete(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return [...this._store(storeName).keys()];
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
this._store(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
let result;
|
||||
let idx = 0;
|
||||
for (const [key, value] of this._store(storeName)) {
|
||||
result = fn(value, key, idx);
|
||||
idx++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupLocalForageMock(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:localForage', MockLocalForageService);
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class MockNostrDataService extends Service {
|
||||
}
|
||||
|
||||
class MockOsmService extends Service {
|
||||
getCachedOsmObject() {
|
||||
async getCachedOsmObject() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ class MockOsmService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
module('Unit | Service | contributions', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
@@ -58,12 +64,6 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
localStorage.removeItem('marco:contributions:name_cache');
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
localStorage.removeItem('marco:contributions:name_cache');
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from bookmarks immediately', function (assert) {
|
||||
@@ -85,7 +85,7 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from the persistent name cache', function (assert) {
|
||||
test('_updateItems resolves names from the persistent name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
@@ -95,36 +95,66 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service._nameCache.set('osm:node:999', 'Cached Park');
|
||||
await service.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
'osm:node:999',
|
||||
'Cached Park'
|
||||
);
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Cached Park');
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
});
|
||||
|
||||
test('name cache is persisted to localStorage', function (assert) {
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service._nameCache.set('osm:node:42', 'Test Place');
|
||||
service._saveNameCache();
|
||||
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:42',
|
||||
}),
|
||||
];
|
||||
|
||||
const raw = localStorage.getItem('marco:contributions:name_cache');
|
||||
assert.ok(raw, 'Cache entry exists in localStorage');
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed['osm:node:42'], 'Test Place');
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
const map = new Map();
|
||||
map.set('node:42', { title: 'Test Place' });
|
||||
return map;
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
|
||||
'Test Place',
|
||||
'Name is persisted to the IndexedDB name cache'
|
||||
);
|
||||
});
|
||||
|
||||
test('name cache is loaded from localStorage on construction', function (assert) {
|
||||
localStorage.setItem(
|
||||
'marco:contributions:name_cache',
|
||||
JSON.stringify({ 'osm:node:77': 'Persisted Place' })
|
||||
);
|
||||
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:77',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
assert.strictEqual(
|
||||
service._nameCache.get('osm:node:77'),
|
||||
await service.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
'osm:node:77',
|
||||
'Persisted Place'
|
||||
);
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
});
|
||||
|
||||
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
|
||||
@@ -143,7 +173,7 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
service._updateItems(events);
|
||||
|
||||
// Wait for the background batch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.false(
|
||||
service.items[0].placeNameLoading,
|
||||
@@ -169,31 +199,18 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
// Fallback should be shown but NOT cached
|
||||
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
|
||||
assert.false(
|
||||
service._nameCache.has('osm:node:666'),
|
||||
'Fallback is not stored in the name cache'
|
||||
assert.notOk(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
|
||||
'No fallback name persisted in the IndexedDB name cache'
|
||||
);
|
||||
assert.true(
|
||||
service._unresolvable.has('osm:node:666'),
|
||||
'Item is marked unresolvable for this session'
|
||||
);
|
||||
|
||||
// Verify the fallback was NOT persisted to localStorage
|
||||
service._saveNameCache();
|
||||
const raw = localStorage.getItem('marco:contributions:name_cache');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.notOk(
|
||||
parsed['osm:node:666'],
|
||||
'No fallback name persisted in localStorage'
|
||||
);
|
||||
} else {
|
||||
assert.true(true, 'No cache entry was persisted');
|
||||
}
|
||||
});
|
||||
|
||||
test('unresolvable items are not re-fetched within the same session', async function (assert) {
|
||||
@@ -214,14 +231,14 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
|
||||
// First update triggers a fetch
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
|
||||
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
|
||||
|
||||
// Second update should NOT trigger another fetch (already unresolvable)
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
|
||||
assert.strictEqual(
|
||||
@@ -248,11 +265,11 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
||||
assert.strictEqual(
|
||||
service._nameCache.get('osm:node:111'),
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
|
||||
'Resolved Café',
|
||||
'Name is stored in the name cache'
|
||||
);
|
||||
|
||||
@@ -348,8 +348,8 @@ module('Unit | Service | osm', function (hooks) {
|
||||
'Batch fetch does not write to the in-memory OSM cache'
|
||||
);
|
||||
assert.notOk(
|
||||
localStorage.getItem('marco:osm_cache:node:100'),
|
||||
'Batch fetch does not write to the localStorage OSM cache'
|
||||
await service.localForage.get('osm-cache', 'node:100'),
|
||||
'Batch fetch does not write to the persistent OSM cache'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -432,4 +432,65 @@ module('Unit | Service | osm', function (hooks) {
|
||||
'Uses relations.json endpoint'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObject with forceFresh bypasses the cache and returns fresh data', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
// Seed both caches so we can prove forceFresh skips them.
|
||||
service.cachedPlaces.set('node:5', {
|
||||
data: { title: 'Stale In-Memory', lat: 1, lon: 1 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await service.localForage.set('osm-cache', 'node:5', {
|
||||
data: { title: 'Stale IndexedDB', lat: 1, lon: 1 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
service.fetchWithRetry = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{
|
||||
id: 5,
|
||||
type: 'node',
|
||||
lat: 2,
|
||||
lon: 3,
|
||||
tags: { name: 'Fresh From API' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.fetchOsmObject('5', 'node', {
|
||||
forceFresh: true,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.title,
|
||||
'Fresh From API',
|
||||
'Returns fresh API data, not cached data'
|
||||
);
|
||||
assert.strictEqual(result.lat, 2);
|
||||
assert.strictEqual(result.lon, 3);
|
||||
});
|
||||
|
||||
test('fetchOsmObject without forceFresh returns the in-memory cache entry without hitting the API', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service.cachedPlaces.set('node:6', {
|
||||
data: { title: 'Warm In-Memory', lat: 4, lon: 5 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
let fetchCalled = 0;
|
||||
service.fetchWithRetry = async () => {
|
||||
fetchCalled++;
|
||||
return { ok: true, json: async () => ({ elements: [] }) };
|
||||
};
|
||||
|
||||
const result = await service.fetchOsmObject('6', 'node');
|
||||
|
||||
assert.strictEqual(fetchCalled, 0, 'API was not hit');
|
||||
assert.strictEqual(result.title, 'Warm In-Memory');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,10 @@ module('Unit | Service | storage', function (hooks) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
// Stub OSM Service
|
||||
let capturedOptions;
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
async fetchOsmObject(id, type, options) {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
@@ -49,6 +51,10 @@ module('Unit | Service | storage', function (hooks) {
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
||||
assert.ok(
|
||||
capturedOptions?.forceFresh,
|
||||
'refreshPlace fetches fresh OSM data (forceFresh: true)'
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||
|
||||
Reference in New Issue
Block a user