Merge pull request 'Migrate OSM cache to localForage/IndexedDB' (#76) from feature/indexeddb_cache into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m16s

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-08-19 17:15:58 +00:00
14 changed files with 1543 additions and 1242 deletions
+2 -2
View File
@@ -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
View File
@@ -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);
}
}
+85 -56
View File
@@ -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;
}
}
+55
View File
@@ -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
View File
@@ -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 {
+2 -1
View File
@@ -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) {
+1
View File
@@ -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",
+1117 -1094
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ class MockOsmService extends Service {
}
getCachedOsmObject() {
return null;
return Promise.resolve(null);
}
}
+4
View File
@@ -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.
}
+59
View File
@@ -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);
});
}
+63 -46
View File
@@ -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'
);
+63 -2
View File
@@ -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');
});
});
+7 -1
View File
@@ -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) {