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: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 11
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 11
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
+39
-4
@@ -1,6 +1,23 @@
|
|||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
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 {
|
export default class PlaceRoute extends Route {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -131,14 +148,32 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkUpdates(place) {
|
async checkUpdates(place) {
|
||||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
if (!place || !place.osmId || !place.osmType) return;
|
||||||
if (place && place.id && place.osmId && place.osmType) {
|
|
||||||
|
// Bookmarked place — refresh via storage, which persists the update.
|
||||||
|
if (place.id) {
|
||||||
const updatedPlace = await this.storage.refreshPlace(place);
|
const updatedPlace = await this.storage.refreshPlace(place);
|
||||||
if (updatedPlace) {
|
if (updatedPlace) {
|
||||||
// If an update occurred, refresh the map UI selection without moving the camera
|
// If an update occurred, refresh the map UI selection without moving
|
||||||
// This ensures the sidebar shows the new data
|
// the camera. This ensures the sidebar shows the new data.
|
||||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
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 { tracked } from '@glimmer/tracking';
|
||||||
import { groupPhotoContributions } from '../utils/contributions';
|
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
|
* 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:
|
* The flow is:
|
||||||
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
||||||
* 2. Group events into contribution entries via `groupPhotoContributions`.
|
* 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.
|
* 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
|
* 5. Any items that can't be resolved get a fallback name so they don't stay
|
||||||
* stuck in a "Loading…" state forever.
|
* stuck in a "Loading…" state forever.
|
||||||
* 6. Update `@tracked items` so the UI renders progressively.
|
* 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 {
|
export default class ContributionsService extends Service {
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service localForage;
|
||||||
|
|
||||||
@tracked items = [];
|
@tracked items = [];
|
||||||
|
|
||||||
_sub = null;
|
_sub = null;
|
||||||
_pendingBatchPromise = null;
|
_pendingBatchPromise = null;
|
||||||
_lastBatchSignature = '';
|
_lastBatchSignature = '';
|
||||||
_nameCache = new Map();
|
|
||||||
_unresolvable = new Set();
|
_unresolvable = new Set();
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
super(...arguments);
|
* Async name resolution. Checks, in order:
|
||||||
this._loadNameCache();
|
* 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;
|
* Asynchronously resolves names for still-loading entries from the caches.
|
||||||
try {
|
* Falls through to `_maybeBatchFetchNames` for anything that remains
|
||||||
const raw = localStorage.getItem(NAME_CACHE_KEY);
|
* unresolved. Fire-and-forget from `_updateItems` so the list renders
|
||||||
if (raw) {
|
* immediately with bookmark-resolved names.
|
||||||
const obj = JSON.parse(raw);
|
*
|
||||||
if (obj && typeof obj === 'object') {
|
* @param {Array} entries
|
||||||
this._nameCache = new Map(Object.entries(obj));
|
* @returns {Promise<void>}
|
||||||
}
|
*/
|
||||||
}
|
async _resolveFromCache(entries) {
|
||||||
} catch {
|
const pending = entries.filter((e) => e.placeNameLoading);
|
||||||
// ignore malformed cache
|
if (pending.length === 0) {
|
||||||
}
|
this._maybeBatchFetchNames(this.items);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveNameCache() {
|
await Promise.all(
|
||||||
if (typeof localStorage === 'undefined') return;
|
pending.map(async (entry) => {
|
||||||
try {
|
const name = await this._resolveCachedName(entry);
|
||||||
const obj = Object.fromEntries(this._nameCache);
|
if (name) {
|
||||||
localStorage.setItem(NAME_CACHE_KEY, JSON.stringify(obj));
|
entry.placeName = name;
|
||||||
} catch (e) {
|
entry.placeNameLoading = false;
|
||||||
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)
|
// 1. Group events into contribution entries (newest-first)
|
||||||
const entries = groupPhotoContributions(events);
|
const entries = groupPhotoContributions(events);
|
||||||
|
|
||||||
// 2. Resolve place names: preserve previously-resolved names, then check
|
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||||
// bookmarks, the name cache, and the OSM service cache.
|
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const cached = this._resolveCachedName(entry);
|
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||||
if (cached) {
|
if (bookmark?.title) {
|
||||||
entry.placeName = cached;
|
entry.placeName = bookmark.title;
|
||||||
entry.placeNameLoading = false;
|
entry.placeNameLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.items = entries;
|
this.items = entries;
|
||||||
|
|
||||||
// 3. Trigger a background batch fetch for any still-unresolved names.
|
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||||
// De-duplicate so we don't re-fetch the same set while a fetch is in-flight.
|
// cache, then fall through to the network batch for the rest.
|
||||||
this._maybeBatchFetchNames(entries);
|
void this._resolveFromCache(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_isUnresolvable(entry) {
|
_isUnresolvable(entry) {
|
||||||
@@ -177,7 +198,7 @@ export default class ContributionsService extends Service {
|
|||||||
|
|
||||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||||
.then((nameMap) => {
|
.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) {
|
for (const item of this.items) {
|
||||||
if (!item.placeNameLoading) continue;
|
if (!item.placeNameLoading) continue;
|
||||||
if (nameMap.has(item.placeIdentifier)) {
|
if (nameMap.has(item.placeIdentifier)) {
|
||||||
@@ -192,7 +213,6 @@ export default class ContributionsService extends Service {
|
|||||||
item.placeNameLoading = false;
|
item.placeNameLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._saveNameCache();
|
|
||||||
// Trigger a re-render
|
// Trigger a re-render
|
||||||
this.items = [...this.items];
|
this.items = [...this.items];
|
||||||
})
|
})
|
||||||
@@ -206,7 +226,6 @@ export default class ContributionsService extends Service {
|
|||||||
item.placeNameLoading = false;
|
item.placeNameLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._saveNameCache();
|
|
||||||
this.items = [...this.items];
|
this.items = [...this.items];
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -224,7 +243,7 @@ export default class ContributionsService extends Service {
|
|||||||
|
|
||||||
// Re-check cache in case it was populated between the trigger and now
|
// Re-check cache in case it was populated between the trigger and now
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const cached = this._resolveCachedName(entry);
|
const cached = await this._resolveCachedName(entry);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
nameMap.set(entry.placeIdentifier, cached);
|
nameMap.set(entry.placeIdentifier, cached);
|
||||||
} else {
|
} else {
|
||||||
@@ -235,16 +254,26 @@ export default class ContributionsService extends Service {
|
|||||||
if (toFetch.length === 0) return nameMap;
|
if (toFetch.length === 0) return nameMap;
|
||||||
|
|
||||||
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
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) {
|
for (const entry of entries) {
|
||||||
if (nameMap.has(entry.placeIdentifier)) continue;
|
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||||
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||||
const place = places.get(cacheKey);
|
const place = places.get(cacheKey);
|
||||||
if (place?.title) {
|
if (place?.title) {
|
||||||
nameMap.set(entry.placeIdentifier, 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;
|
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 {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service localForage;
|
||||||
|
|
||||||
controller = null;
|
controller = null;
|
||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
cachedPlaces = new Map();
|
cachedPlaces = new Map();
|
||||||
|
|
||||||
// Long-term cache for OSM place metadata, persisted to localStorage so that
|
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
|
||||||
// names and basic info survive across sessions and can be rendered instantly
|
// `localForage` service) so that names and basic info survive across
|
||||||
// without waiting on the OSM API. Entries are refreshed in the background.
|
// sessions and can be rendered instantly without waiting on the OSM API.
|
||||||
static CACHE_KEY_PREFIX = 'marco:osm_cache:';
|
// Entries are refreshed in the background.
|
||||||
|
static STORE_NAME = 'osm-cache';
|
||||||
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
||||||
|
|
||||||
@@ -21,27 +23,24 @@ export default class OsmService extends Service {
|
|||||||
return `${osmType}:${osmId}`;
|
return `${osmType}:${osmId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_readLocalCache(osmType, osmId) {
|
async _readLocalCache(osmType, osmId) {
|
||||||
if (typeof localStorage === 'undefined') return null;
|
const key = this._buildCacheKey(osmType, osmId);
|
||||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
if (
|
if (
|
||||||
!parsed ||
|
!parsed ||
|
||||||
typeof parsed.timestamp !== 'number' ||
|
typeof parsed.timestamp !== 'number' ||
|
||||||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||||
) {
|
) {
|
||||||
localStorage.removeItem(key);
|
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(
|
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||||
`${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -49,31 +48,32 @@ export default class OsmService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeLocalCache(osmType, osmId, data) {
|
async _writeLocalCache(osmType, osmId, data) {
|
||||||
if (typeof localStorage === 'undefined' || !data) return;
|
if (!data) return;
|
||||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
const key = this._buildCacheKey(osmType, osmId);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||||
key,
|
data,
|
||||||
JSON.stringify({ data, timestamp: Date.now() })
|
timestamp: Date.now(),
|
||||||
);
|
});
|
||||||
} catch (e) {
|
} 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
|
* Async lookup for an OSM place. Checks the short-lived in-memory cache
|
||||||
* first, then the persistent localStorage cache. Returns `null` if not cached.
|
* 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
|
* Use this for instant rendering (e.g. place names in a list) and fall
|
||||||
* `fetchOsmObject` for a fresh fetch + background refresh.
|
* back to `fetchOsmObject` for a fresh fetch + background refresh.
|
||||||
*
|
*
|
||||||
* @param {string} osmType 'node' | 'way' | 'relation'
|
* @param {string} osmType 'node' | 'way' | 'relation'
|
||||||
* @param {string} osmId
|
* @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;
|
if (!osmType || !osmId) return null;
|
||||||
|
|
||||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||||
@@ -103,7 +103,9 @@ export default class OsmService extends Service {
|
|||||||
this.cachedPlaces.delete(cacheKey);
|
this.cachedPlaces.delete(cacheKey);
|
||||||
}, OsmService.IN_MEMORY_TTL_MS);
|
}, 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() {
|
cancelAll() {
|
||||||
@@ -326,22 +328,30 @@ out center;
|
|||||||
return this.normalizePoi(data.elements[0]);
|
return this.normalizePoi(data.elements[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchOsmObject(osmId, osmType) {
|
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||||
if (!osmId || !osmType) return null;
|
if (!osmId || !osmType) return null;
|
||||||
|
|
||||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
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);
|
const cached = this.cachedPlaces.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a persistent (localStorage) cache entry, return it immediately and
|
// Otherwise return the persistent IndexedDB entry and refresh in the
|
||||||
// kick off a background refresh. This keeps the UI snappy while still ensuring
|
// background so the next visit is fresh.
|
||||||
// the cache is updated with the latest OSM data.
|
const localCached = await this._readLocalCache(osmType, osmId);
|
||||||
const localCached = this._readLocalCache(osmType, osmId);
|
|
||||||
if (localCached) {
|
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.
|
// Refresh in the background, but don't block the caller.
|
||||||
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
||||||
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
||||||
@@ -415,7 +425,7 @@ out center;
|
|||||||
for (const { osmType, osmId } of items) {
|
for (const { osmType, osmId } of items) {
|
||||||
if (!osmType || !osmId) continue;
|
if (!osmType || !osmId) continue;
|
||||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||||
const cached = this.getCachedOsmObject(osmType, osmId);
|
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
result.set(cacheKey, cached);
|
result.set(cacheKey, cached);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -426,7 +426,8 @@ export default class StorageService extends Service {
|
|||||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||||
const freshData = await this.osm.fetchOsmObject(
|
const freshData = await this.osm.fetchOsmObject(
|
||||||
place.osmId,
|
place.osmId,
|
||||||
place.osmType
|
place.osmType,
|
||||||
|
{ forceFresh: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!freshData) {
|
if (!freshData) {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.1.0",
|
"ember-lifeline": "^7.1.0",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
"nostr-idb": "^5.1.0",
|
"nostr-idb": "^5.1.0",
|
||||||
"oauth2-pkce": "^3.0.0",
|
"oauth2-pkce": "^3.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"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() {
|
getCachedOsmObject() {
|
||||||
return null;
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
setupTest as upstreamSetupTest,
|
setupTest as upstreamSetupTest,
|
||||||
} from 'ember-qunit';
|
} from 'ember-qunit';
|
||||||
import { setupNostrMocks } from './mock-nostr';
|
import { setupNostrMocks } from './mock-nostr';
|
||||||
|
import { setupLocalForageMock } from './mock-local-forage';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|
||||||
function setupMapStyleMocks(hooks) {
|
function setupMapStyleMocks(hooks) {
|
||||||
@@ -98,6 +99,7 @@ function setupMapStyleMocks(hooks) {
|
|||||||
function setupApplicationTest(hooks, options) {
|
function setupApplicationTest(hooks, options) {
|
||||||
upstreamSetupApplicationTest(hooks, options);
|
upstreamSetupApplicationTest(hooks, options);
|
||||||
setupNostrMocks(hooks);
|
setupNostrMocks(hooks);
|
||||||
|
setupLocalForageMock(hooks);
|
||||||
setupMapStyleMocks(hooks);
|
setupMapStyleMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for application tests can be done here.
|
// Additional setup for application tests can be done here.
|
||||||
@@ -119,6 +121,7 @@ function setupApplicationTest(hooks, options) {
|
|||||||
function setupRenderingTest(hooks, options) {
|
function setupRenderingTest(hooks, options) {
|
||||||
upstreamSetupRenderingTest(hooks, options);
|
upstreamSetupRenderingTest(hooks, options);
|
||||||
setupNostrMocks(hooks);
|
setupNostrMocks(hooks);
|
||||||
|
setupLocalForageMock(hooks);
|
||||||
|
|
||||||
// Additional setup for rendering tests can be done here.
|
// Additional setup for rendering tests can be done here.
|
||||||
}
|
}
|
||||||
@@ -126,6 +129,7 @@ function setupRenderingTest(hooks, options) {
|
|||||||
function setupTest(hooks, options) {
|
function setupTest(hooks, options) {
|
||||||
upstreamSetupTest(hooks, options);
|
upstreamSetupTest(hooks, options);
|
||||||
setupNostrMocks(hooks);
|
setupNostrMocks(hooks);
|
||||||
|
setupLocalForageMock(hooks);
|
||||||
|
|
||||||
// Additional setup for unit tests can be done here.
|
// 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 {
|
class MockOsmService extends Service {
|
||||||
getCachedOsmObject() {
|
async getCachedOsmObject() {
|
||||||
return null;
|
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) {
|
module('Unit | Service | contributions', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
|
|
||||||
@@ -58,12 +64,6 @@ module('Unit | Service | contributions', function (hooks) {
|
|||||||
this.owner.register('service:storage', MockStorageService);
|
this.owner.register('service:storage', MockStorageService);
|
||||||
this.owner.register('service:nostrData', MockNostrDataService);
|
this.owner.register('service:nostrData', MockNostrDataService);
|
||||||
this.owner.register('service:osm', MockOsmService);
|
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) {
|
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 = [
|
const events = [
|
||||||
makePhotoEvent({
|
makePhotoEvent({
|
||||||
id: 'e1',
|
id: 'e1',
|
||||||
@@ -95,36 +95,66 @@ module('Unit | Service | contributions', function (hooks) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const service = this.owner.lookup('service:contributions');
|
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);
|
service._updateItems(events);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
assert.strictEqual(service.items[0].placeName, 'Cached Park');
|
assert.strictEqual(service.items[0].placeName, 'Cached Park');
|
||||||
assert.false(service.items[0].placeNameLoading);
|
assert.false(service.items[0].placeNameLoading);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('name cache is persisted to localStorage', function (assert) {
|
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
|
||||||
const service = this.owner.lookup('service:contributions');
|
const events = [
|
||||||
service._nameCache.set('osm:node:42', 'Test Place');
|
makePhotoEvent({
|
||||||
service._saveNameCache();
|
id: 'e1',
|
||||||
|
created_at: 1000,
|
||||||
|
placeIdentifier: 'osm:node:42',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const raw = localStorage.getItem('marco:contributions:name_cache');
|
const service = this.owner.lookup('service:contributions');
|
||||||
assert.ok(raw, 'Cache entry exists in localStorage');
|
service.osm.fetchOsmObjectsBatch = async () => {
|
||||||
const parsed = JSON.parse(raw);
|
const map = new Map();
|
||||||
assert.strictEqual(parsed['osm:node:42'], 'Test Place');
|
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) {
|
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
|
||||||
localStorage.setItem(
|
const events = [
|
||||||
'marco:contributions:name_cache',
|
makePhotoEvent({
|
||||||
JSON.stringify({ 'osm:node:77': 'Persisted Place' })
|
id: 'e1',
|
||||||
);
|
created_at: 1000,
|
||||||
|
placeIdentifier: 'osm:node:77',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const service = this.owner.lookup('service:contributions');
|
const service = this.owner.lookup('service:contributions');
|
||||||
assert.strictEqual(
|
await service.localForage.set(
|
||||||
service._nameCache.get('osm:node:77'),
|
NAME_CACHE_STORE,
|
||||||
|
'osm:node:77',
|
||||||
'Persisted Place'
|
'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) {
|
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);
|
service._updateItems(events);
|
||||||
|
|
||||||
// Wait for the background batch to complete
|
// Wait for the background batch to complete
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await flushPromises();
|
||||||
|
|
||||||
assert.false(
|
assert.false(
|
||||||
service.items[0].placeNameLoading,
|
service.items[0].placeNameLoading,
|
||||||
@@ -169,31 +199,18 @@ module('Unit | Service | contributions', function (hooks) {
|
|||||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||||
|
|
||||||
service._updateItems(events);
|
service._updateItems(events);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await flushPromises();
|
||||||
|
|
||||||
// Fallback should be shown but NOT cached
|
// Fallback should be shown but NOT cached
|
||||||
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
|
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
|
||||||
assert.false(
|
assert.notOk(
|
||||||
service._nameCache.has('osm:node:666'),
|
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
|
||||||
'Fallback is not stored in the name cache'
|
'No fallback name persisted in the IndexedDB name cache'
|
||||||
);
|
);
|
||||||
assert.true(
|
assert.true(
|
||||||
service._unresolvable.has('osm:node:666'),
|
service._unresolvable.has('osm:node:666'),
|
||||||
'Item is marked unresolvable for this session'
|
'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) {
|
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
|
// First update triggers a fetch
|
||||||
service._updateItems(events);
|
service._updateItems(events);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await flushPromises();
|
||||||
|
|
||||||
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
|
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
|
||||||
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
|
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
|
||||||
|
|
||||||
// Second update should NOT trigger another fetch (already unresolvable)
|
// Second update should NOT trigger another fetch (already unresolvable)
|
||||||
service._updateItems(events);
|
service._updateItems(events);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await flushPromises();
|
||||||
|
|
||||||
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
|
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@@ -248,11 +265,11 @@ module('Unit | Service | contributions', function (hooks) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service._updateItems(events);
|
service._updateItems(events);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await flushPromises();
|
||||||
|
|
||||||
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
service._nameCache.get('osm:node:111'),
|
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
|
||||||
'Resolved Café',
|
'Resolved Café',
|
||||||
'Name is stored in the name cache'
|
'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'
|
'Batch fetch does not write to the in-memory OSM cache'
|
||||||
);
|
);
|
||||||
assert.notOk(
|
assert.notOk(
|
||||||
localStorage.getItem('marco:osm_cache:node:100'),
|
await service.localForage.get('osm-cache', 'node:100'),
|
||||||
'Batch fetch does not write to the localStorage OSM cache'
|
'Batch fetch does not write to the persistent OSM cache'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,4 +432,65 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
'Uses relations.json endpoint'
|
'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');
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
// Stub OSM Service
|
// Stub OSM Service
|
||||||
|
let capturedOptions;
|
||||||
class OsmStub extends Service {
|
class OsmStub extends Service {
|
||||||
async fetchOsmObject(id, type) {
|
async fetchOsmObject(id, type, options) {
|
||||||
|
capturedOptions = options;
|
||||||
return {
|
return {
|
||||||
osmId: id,
|
osmId: id,
|
||||||
osmType: type,
|
osmType: type,
|
||||||
@@ -49,6 +51,10 @@ module('Unit | Service | storage', function (hooks) {
|
|||||||
|
|
||||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||||
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
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) {
|
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||||
|
|||||||
Reference in New Issue
Block a user