Migrate OSM cache to localForage/IndexedDB

We don't want to hit limits with localStorage, and it's non-blocking
This commit is contained in:
2026-08-19 10:39:58 -06:00
parent 45f6f898fa
commit 61242420d1
10 changed files with 1421 additions and 1231 deletions
+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'
);
+2 -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'
);
});