From a782208b95406a8fc40c610736708d35df8fdef6 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 16 Feb 2026 11:34:18 +0100 Subject: [PATCH 1/6] feat: support FeatureCollection for Live-data-Sources --- src/renderer/components/map/SSEVectorSource.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/map/SSEVectorSource.js b/src/renderer/components/map/SSEVectorSource.js index bd5b66a8..94d1165e 100644 --- a/src/renderer/components/map/SSEVectorSource.js +++ b/src/renderer/components/map/SSEVectorSource.js @@ -233,7 +233,13 @@ class SSEVectorSource extends VectorSource { try { /** @type {GeoJSONData} */ const data = JSON.parse(event.data) - if (data.id) { + if (data.type === 'FeatureCollection' && data.features) { + data.features.forEach(feature => { + if (feature.id) { + feature.id = `${this.idPrefix}${feature.id}` + } + }) + } else if (data.id) { data.id = `${this.idPrefix}${data.id}` } if (this.onMessage) { From a6135ae4cf4efe85e87c588d8ccb2ba5bceca59f Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 16 Feb 2026 13:55:21 +0100 Subject: [PATCH 2/6] feat: change opacity of SSE-service layers as well as specifiy properties of the heatmap --- .../components/map/SSEVectorSource.js | 67 +++++- .../properties/SSEServiceProperties.css | 29 +++ .../properties/SSEServiceProperties.js | 208 +++++++++++++++++- .../model/commands/CreationCommands.js | 9 +- src/renderer/store/SSELayerStore.js | 86 +++++++- 5 files changed, 375 insertions(+), 24 deletions(-) diff --git a/src/renderer/components/map/SSEVectorSource.js b/src/renderer/components/map/SSEVectorSource.js index 94d1165e..aa4bece5 100644 --- a/src/renderer/components/map/SSEVectorSource.js +++ b/src/renderer/components/map/SSEVectorSource.js @@ -84,7 +84,7 @@ class SSEVectorSource extends VectorSource { strategy: function (extent, resolution) { if (state.resolution !== resolution) { state.resolution = resolution - this.getFeatures().map(feature => feature.$.centerResolution(resolution)) + this.getFeatures().forEach(feature => feature.$?.centerResolution(resolution)) } return [extent] } @@ -128,6 +128,30 @@ class SSEVectorSource extends VectorSource { */ this.useFeatureIds = options.useFeatureIds !== false + /** + * Render mode: 'vector' for normal rendering, 'heatmap' for density visualization + * @type {string} + */ + this.renderMode = options.renderMode || 'vector' + + /** + * Rate limiting interval for heatmap accumulation in milliseconds + * @type {number} + */ + this.heatmapInterval = options.heatmapInterval || 1000 + + /** + * Timestamp of last heatmap accumulation (ms since epoch) + * @type {number} + */ + this.lastHeatmapUpdate = 0 + + /** + * Maximum number of accumulated heatmap features before oldest are dropped + * @type {number} + */ + this.maxHeatmapFeatures = options.maxHeatmapFeatures || 50000 + /** * Prefix added to feature IDs * @type {string} @@ -285,6 +309,8 @@ class SSEVectorSource extends VectorSource { this.timerId = null } + this.lastHeatmapUpdate = 0 + this.dispatchEvent({ type: 'sse-disconnected', source: this @@ -336,7 +362,13 @@ class SSEVectorSource extends VectorSource { console.warn('Unknown feature type ' + this.pendingData.type) } - if (this.useFeatureIds) { + if (this.renderMode === 'heatmap') { + const now = Date.now() + if (now - this.lastHeatmapUpdate >= this.heatmapInterval) { + this.accumulateFeatures(features) + this.lastHeatmapUpdate = now + } + } else if (this.useFeatureIds) { this.updateFeaturesById(features) } else { this.replaceAllFeatures(features) @@ -401,6 +433,37 @@ class SSEVectorSource extends VectorSource { this.addFeatures(olFeatures) } + /** + * Accumulates features without clearing existing ones (for heatmap mode). + * Uses lightweight format.readFeature() instead of signal-based featureReader. + * Maps properties.confidence to a 'weight' property for heatmap rendering. + * + * @param {GeoJSONFeature[]} features - Array of GeoJSON features + * @returns {void} + */ + accumulateFeatures (features) { + const olFeatures = features.map(feature => { + const reprojectedFeature = { + ...feature, + geometry: this.reprojectGeometry(feature.geometry) + } + const olFeature = format.readFeature(reprojectedFeature) + const confidence = feature.properties?.confidence + olFeature.set('weight', confidence != null ? confidence : 0.5) + return olFeature + }) + this.addFeatures(olFeatures) + + // Evict oldest features when cap is exceeded + const all = this.getFeatures() + const excess = all.length - this.maxHeatmapFeatures + if (excess > 0) { + for (let i = 0; i < excess; i++) { + this.removeFeature(all[i]) + } + } + } + /** * Updates existing features or adds new ones based on feature ID. * Features without IDs are skipped with a warning. diff --git a/src/renderer/components/properties/SSEServiceProperties.css b/src/renderer/components/properties/SSEServiceProperties.css index 10dbc887..e3e80f97 100644 --- a/src/renderer/components/properties/SSEServiceProperties.css +++ b/src/renderer/components/properties/SSEServiceProperties.css @@ -1,3 +1,32 @@ +.sse-section-label { + font-size: 0.85rem; + font-weight: 500; + color: #666; +} + +.sse-render-mode { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.sse-radio-group { + display: flex; + gap: 16px; +} + +.sse-radio-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.sse-radio-label input:disabled + span { + color: #999; +} + .sse-option-row { display: flex; align-items: center; diff --git a/src/renderer/components/properties/SSEServiceProperties.js b/src/renderer/components/properties/SSEServiceProperties.js index ce099b88..f1e671b4 100644 --- a/src/renderer/components/properties/SSEServiceProperties.js +++ b/src/renderer/components/properties/SSEServiceProperties.js @@ -1,6 +1,7 @@ /* eslint-disable react/prop-types */ import React from 'react' import TextField from './TextField' +import Range from './Range' import FlexColumnGap from './FlexColumnGap' import Name from './Name' import { useServices } from '../hooks' @@ -13,8 +14,17 @@ const SSEServiceProperties = props => { const [url, setUrl] = React.useState({ dirty: false, value: service.url || '' }) const [eventType, setEventType] = React.useState({ dirty: false, value: service.eventType || 'message' }) const [updateInterval, setUpdateInterval] = React.useState({ dirty: false, value: service.updateInterval || 100 }) + const [heatmapRadius, setHeatmapRadius] = React.useState({ dirty: false, value: service.heatmapRadius ?? 20 }) + const [heatmapBlur, setHeatmapBlur] = React.useState({ dirty: false, value: service.heatmapBlur ?? 15 }) + const [heatmapOpacity, setHeatmapOpacity] = React.useState({ dirty: false, value: service.heatmapOpacity ?? 0.75 }) + const [heatmapInterval, setHeatmapInterval] = React.useState({ dirty: false, value: service.heatmapInterval ?? 1000 }) + const [maxHeatmapFeatures, setMaxHeatmapFeatures] = React.useState({ dirty: false, value: service.maxHeatmapFeatures ?? 50000 }) + const [vectorOpacity, setVectorOpacity] = React.useState({ dirty: false, value: service.vectorOpacity ?? 1 }) const [stats, setStats] = React.useState({ isConnected: false, featureCount: 0 }) + const renderMode = service.renderMode || 'vector' + const isHeatmap = renderMode === 'heatmap' + // Update stats periodically React.useEffect(() => { const updateStats = () => { @@ -33,6 +43,12 @@ const SSEServiceProperties = props => { setUrl({ dirty: false, value: service.url || '' }) setEventType({ dirty: false, value: service.eventType || 'message' }) setUpdateInterval({ dirty: false, value: service.updateInterval || 100 }) + setHeatmapRadius({ dirty: false, value: service.heatmapRadius ?? 20 }) + setHeatmapBlur({ dirty: false, value: service.heatmapBlur ?? 15 }) + setHeatmapOpacity({ dirty: false, value: service.heatmapOpacity ?? 0.75 }) + setHeatmapInterval({ dirty: false, value: service.heatmapInterval ?? 1000 }) + setMaxHeatmapFeatures({ dirty: false, value: service.maxHeatmapFeatures ?? 50000 }) + setVectorOpacity({ dirty: false, value: service.vectorOpacity ?? 1 }) }, [key]) // eslint-disable-line react-hooks/exhaustive-deps const updateService = async (updates) => { @@ -76,6 +92,71 @@ const SSEServiceProperties = props => { updateService({ updateInterval: updateInterval.value }) } + const handleRenderModeChange = (mode) => { + if (mode === renderMode) return + updateService({ renderMode: mode }) + } + + const handleHeatmapRadiusChange = ({ target }) => { + const value = parseInt(target.value, 10) || 20 + if (heatmapRadius.value === value) return + setHeatmapRadius({ dirty: true, value }) + } + + const handleHeatmapRadiusBlur = () => { + if (!heatmapRadius.dirty) return + setHeatmapRadius({ dirty: false, value: heatmapRadius.value }) + updateService({ heatmapRadius: heatmapRadius.value }) + } + + const handleHeatmapBlurChange = ({ target }) => { + const value = parseInt(target.value, 10) || 15 + if (heatmapBlur.value === value) return + setHeatmapBlur({ dirty: true, value }) + } + + const handleHeatmapBlurBlur = () => { + if (!heatmapBlur.dirty) return + setHeatmapBlur({ dirty: false, value: heatmapBlur.value }) + updateService({ heatmapBlur: heatmapBlur.value }) + } + + const handleHeatmapOpacityChange = ({ target }) => { + const value = Number.parseFloat(target.value) + setHeatmapOpacity({ dirty: false, value }) + updateService({ heatmapOpacity: value }) + } + + const handleHeatmapIntervalChange = ({ target }) => { + const value = parseInt(target.value, 10) || 1000 + if (heatmapInterval.value === value) return + setHeatmapInterval({ dirty: true, value }) + } + + const handleHeatmapIntervalBlur = () => { + if (!heatmapInterval.dirty) return + setHeatmapInterval({ dirty: false, value: heatmapInterval.value }) + updateService({ heatmapInterval: heatmapInterval.value }) + } + + const handleMaxHeatmapFeaturesChange = ({ target }) => { + const value = parseInt(target.value, 10) || 50000 + if (maxHeatmapFeatures.value === value) return + setMaxHeatmapFeatures({ dirty: true, value }) + } + + const handleMaxHeatmapFeaturesBlur = () => { + if (!maxHeatmapFeatures.dirty) return + setMaxHeatmapFeatures({ dirty: false, value: maxHeatmapFeatures.value }) + updateService({ maxHeatmapFeatures: maxHeatmapFeatures.value }) + } + + const handleVectorOpacityChange = ({ target }) => { + const value = Number.parseFloat(target.value) + setVectorOpacity({ dirty: false, value }) + updateService({ vectorOpacity: value }) + } + const handleEnabledChange = ({ target }) => { updateService({ enabled: target.checked }) } @@ -126,18 +207,123 @@ const SSEServiceProperties = props => { /> -
- +
+ Render Mode +
+ + +
- + + + {isHeatmap && ( + <> + + + + + + + + + + + + + + + + + + + )} + + {!isHeatmap && ( + <> + + + + + + +
+ +
+ + + )}
-
- - {connectionStatusText} -
+
- {stats.isConnected && ( -
-
- Features: - {stats.featureCount} -
-
- Messages: - {stats.messagesReceived} -
-
- Updates: - {stats.mapUpdates} -
-
- )} + ) } From b4f25457d74962e14da3c77367c114b9fb7b3b09 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Fri, 24 Apr 2026 13:12:42 +0200 Subject: [PATCH 4/6] fix: allow clearing numeric SSE property fields while editing The change handlers coerced empty input to the default via `parseInt('') || default`, which snapped state back to the default on every keystroke that produced an empty or non-numeric intermediate value. Now the raw string is kept in state during editing and only parsed (with defaulting) on blur. Affects updateInterval, heatmapRadius, heatmapBlur, heatmapInterval, maxHeatmapFeatures. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../properties/SSEServiceProperties.js | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/renderer/components/properties/SSEServiceProperties.js b/src/renderer/components/properties/SSEServiceProperties.js index d09df017..938c036c 100644 --- a/src/renderer/components/properties/SSEServiceProperties.js +++ b/src/renderer/components/properties/SSEServiceProperties.js @@ -153,15 +153,16 @@ const SSEServiceProperties = props => { } const handleUpdateIntervalChange = ({ target }) => { - const value = parseInt(target.value, 10) || 100 - if (updateInterval.value === value) return - setUpdateInterval({ dirty: true, value }) + if (updateInterval.value === target.value) return + setUpdateInterval({ dirty: true, value: target.value }) } const handleUpdateIntervalBlur = () => { if (!updateInterval.dirty) return - setUpdateInterval({ dirty: false, value: updateInterval.value }) - updateService({ updateInterval: updateInterval.value }) + const parsed = parseInt(updateInterval.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 100 + setUpdateInterval({ dirty: false, value }) + updateService({ updateInterval: value }) } const handleRenderModeChange = (mode) => { @@ -170,27 +171,29 @@ const SSEServiceProperties = props => { } const handleHeatmapRadiusChange = ({ target }) => { - const value = parseInt(target.value, 10) || 20 - if (heatmapRadius.value === value) return - setHeatmapRadius({ dirty: true, value }) + if (heatmapRadius.value === target.value) return + setHeatmapRadius({ dirty: true, value: target.value }) } const handleHeatmapRadiusBlur = () => { if (!heatmapRadius.dirty) return - setHeatmapRadius({ dirty: false, value: heatmapRadius.value }) - updateService({ heatmapRadius: heatmapRadius.value }) + const parsed = parseInt(heatmapRadius.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 20 + setHeatmapRadius({ dirty: false, value }) + updateService({ heatmapRadius: value }) } const handleHeatmapBlurChange = ({ target }) => { - const value = parseInt(target.value, 10) || 15 - if (heatmapBlur.value === value) return - setHeatmapBlur({ dirty: true, value }) + if (heatmapBlur.value === target.value) return + setHeatmapBlur({ dirty: true, value: target.value }) } const handleHeatmapBlurBlur = () => { if (!heatmapBlur.dirty) return - setHeatmapBlur({ dirty: false, value: heatmapBlur.value }) - updateService({ heatmapBlur: heatmapBlur.value }) + const parsed = parseInt(heatmapBlur.value, 10) + const value = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15 + setHeatmapBlur({ dirty: false, value }) + updateService({ heatmapBlur: value }) } const handleHeatmapOpacityChange = ({ target }) => { @@ -200,27 +203,29 @@ const SSEServiceProperties = props => { } const handleHeatmapIntervalChange = ({ target }) => { - const value = parseInt(target.value, 10) || 1000 - if (heatmapInterval.value === value) return - setHeatmapInterval({ dirty: true, value }) + if (heatmapInterval.value === target.value) return + setHeatmapInterval({ dirty: true, value: target.value }) } const handleHeatmapIntervalBlur = () => { if (!heatmapInterval.dirty) return - setHeatmapInterval({ dirty: false, value: heatmapInterval.value }) - updateService({ heatmapInterval: heatmapInterval.value }) + const parsed = parseInt(heatmapInterval.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 1000 + setHeatmapInterval({ dirty: false, value }) + updateService({ heatmapInterval: value }) } const handleMaxHeatmapFeaturesChange = ({ target }) => { - const value = parseInt(target.value, 10) || 50000 - if (maxHeatmapFeatures.value === value) return - setMaxHeatmapFeatures({ dirty: true, value }) + if (maxHeatmapFeatures.value === target.value) return + setMaxHeatmapFeatures({ dirty: true, value: target.value }) } const handleMaxHeatmapFeaturesBlur = () => { if (!maxHeatmapFeatures.dirty) return - setMaxHeatmapFeatures({ dirty: false, value: maxHeatmapFeatures.value }) - updateService({ maxHeatmapFeatures: maxHeatmapFeatures.value }) + const parsed = parseInt(maxHeatmapFeatures.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 50000 + setMaxHeatmapFeatures({ dirty: false, value }) + updateService({ maxHeatmapFeatures: value }) } const handleVectorOpacityChange = ({ target }) => { From 6ddf2df3cd9ac79b0dc8de9a881694cdaeea2950 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Thu, 21 May 2026 11:54:50 +0200 Subject: [PATCH 5/6] feat: enable copy & paste for live data sources Live data sources (sse-service entries) could not be duplicated via copy & paste like other objects. - Clipboard.canCopy: accept sse-service ids - Import.clone: rewrite sse-service keys to a fresh id so paste creates a copy instead of overwriting the original; the copy gets a new featureIdPrefix namespace and is inserted with enabled: false - ids.ord: sort all entity types before their dependent entries (link/tags/style). The previous order processed tags before their owning sse-service/tile-service, leaving the remapped tags key broken. - add Import.clone tests for sse-service duplication --- src/renderer/Clipboard.js | 3 +- src/renderer/ids.js | 12 ++++-- src/renderer/model/Import.js | 12 +++++- test/renderer/model/Import-test.js | 67 ++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 test/renderer/model/Import-test.js diff --git a/src/renderer/Clipboard.js b/src/renderer/Clipboard.js index 4368e62b..fc0202cf 100644 --- a/src/renderer/Clipboard.js +++ b/src/renderer/Clipboard.js @@ -7,7 +7,8 @@ const canCopy = id => ID.isLayerId(id) || ID.isFeatureId(id) || ID.isMarkerId(id) || - ID.isTileServiceId(id) + ID.isTileServiceId(id) || + ID.isSSEServiceId(id) /** * @async implicit diff --git a/src/renderer/ids.js b/src/renderer/ids.js index 9168db98..815d4135 100644 --- a/src/renderer/ids.js +++ b/src/renderer/ids.js @@ -219,12 +219,16 @@ export const associatedId = id => { : id.substring(indexStart + 1) } +// Entity types must sort before their dependent entries (link, tags, style), +// because clone() rewrites dependent keys via a keymap populated when the +// owning entity is processed. export const ord = R.cond([ [isLayerId, R.always(0)], [isFeatureId, R.always(1)], [isMarkerId, R.always(2)], - [isLinkId, R.always(3)], - [isTagsId, R.always(4)], - [isStyleId, R.always(5)], - [isTileServiceId, R.always(6)] + [isTileServiceId, R.always(3)], + [isSSEServiceId, R.always(4)], + [isLinkId, R.always(5)], + [isTagsId, R.always(6)], + [isStyleId, R.always(7)] ]) diff --git a/src/renderer/model/Import.js b/src/renderer/model/Import.js index 1a7e8020..3d734fa8 100644 --- a/src/renderer/model/Import.js +++ b/src/renderer/model/Import.js @@ -1,5 +1,6 @@ import * as R from 'ramda' import * as ID from '../ids' +import uuid from '../../shared/uuid' export const clone = async (defaultLayerId, unsorted) => { const entries = unsorted.sort(([a], [b]) => ID.ord(a) - ID.ord(b)) @@ -45,13 +46,22 @@ export const clone = async (defaultLayerId, unsorted) => { [ID.isTagsId, key => replace(key)(ID.tagsId(keymap[ID.associatedId(key)]))], [ID.isMarkerId, key => replace(key)(ID.markerId())], [ID.isTileServiceId, key => replace(key)(ID.tileServiceId())], + [ID.isSSEServiceId, key => replace(key)(ID.sseServiceId())], [ID.isStyleId, key => replace(key)(ID.styleId(keymap[ID.associatedId(key)]))], [R.T, key => key] ]) + // Rewrite values where a plain key remap is not sufficient. + // A duplicated live data source must not share the original's + // feature namespace and must not auto-connect. + + const rewriteValue = (key, value) => ID.isSSEServiceId(key) + ? { ...value, enabled: false, featureIdPrefix: `feature:${uuid()}/` } + : value + // Nasty side-effect: adds tuples (aka acc): entries.reduce((acc, [key, value]) => { - acc.push([rewrite(key), value]) + acc.push([rewrite(key), rewriteValue(key, value)]) return acc }, tuples) diff --git a/test/renderer/model/Import-test.js b/test/renderer/model/Import-test.js new file mode 100644 index 00000000..b2a95dec --- /dev/null +++ b/test/renderer/model/Import-test.js @@ -0,0 +1,67 @@ +import assert from 'assert' +import * as ID from '../../../src/renderer/ids' +import { clone } from '../../../src/renderer/model/Import' + +describe('Import.clone', function () { + + describe('sse-service (live data source)', function () { + + it('rewrites the service key to a new id', async function () { + const original = ID.sseServiceId() + const entries = [[original, { name: 'Source', enabled: true }]] + + const tuples = await clone(null, entries) + + assert.equal(tuples.length, 1) + const [key] = tuples[0] + assert(ID.isSSEServiceId(key)) + assert.notEqual(key, original, 'must not overwrite the original service') + }) + + it('rewrites the associated tags key to the new service id', async function () { + const original = ID.sseServiceId() + const entries = [ + [original, { name: 'Source' }], + [ID.tagsId(original), ['Live Data']] + ] + + const tuples = await clone(null, entries) + const map = Object.fromEntries(tuples) + + const newServiceKey = Object.keys(map).find(ID.isSSEServiceId) + const tagsKey = Object.keys(map).find(k => k.startsWith('tags+')) + assert.equal(tagsKey, ID.tagsId(newServiceKey), 'tags must follow the new service id') + assert.deepEqual(map[tagsKey], ['Live Data']) + }) + + it('inserts the copy disabled', async function () { + const entries = [[ID.sseServiceId(), { name: 'Source', enabled: true }]] + + const tuples = await clone(null, entries) + + assert.equal(tuples[0][1].enabled, false) + }) + + it('assigns the copy a fresh feature namespace', async function () { + const original = ID.sseServiceId() + const featureIdPrefix = 'feature:11111111-1111-1111-1111-111111111111/' + const entries = [[original, { name: 'Source', featureIdPrefix }]] + + const tuples = await clone(null, entries) + + const value = tuples[0][1] + assert.notEqual(value.featureIdPrefix, featureIdPrefix, 'must not share the original namespace') + const uuid = value.featureIdPrefix.replace(/^feature:/, '').replace(/\/$/, '') + assert(ID.isUUID(uuid), 'featureIdPrefix must reference a valid uuid') + }) + + it('does not mutate the original value', async function () { + const value = { name: 'Source', enabled: true } + const entries = [[ID.sseServiceId(), value]] + + await clone(null, entries) + + assert.equal(value.enabled, true) + }) + }) +}) From 326392215e7d7fd32da96e25cfbd19c4b041f801 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Thu, 21 May 2026 12:00:38 +0200 Subject: [PATCH 6/6] fix: guard against crash when deleting a tagged sse-service or marker Deleting a tagged object emits a del op for its tags entry. SearchIndex treats that as an associated change and rebuilds the owner's document, but the owner is already gone, so the document handler received an undefined entry and crashed on `.name`. The tile-service and layer handlers already guard for this; add the same `if (!entry) return null` guard to the sse-service and marker handlers. --- src/renderer/store/documents/marker.js | 2 ++ src/renderer/store/documents/sse-service.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/renderer/store/documents/marker.js b/src/renderer/store/documents/marker.js index 534c73e4..82ac669d 100644 --- a/src/renderer/store/documents/marker.js +++ b/src/renderer/store/documents/marker.js @@ -5,6 +5,8 @@ export default async function (id) { const keys = [R.identity, ID.tagsId] const [marker, tags] = await this.store.collect(id, keys) + if (!marker) return null + return { id, scope: ID.MARKER, diff --git a/src/renderer/store/documents/sse-service.js b/src/renderer/store/documents/sse-service.js index 3c6e7ce8..78b01c5a 100644 --- a/src/renderer/store/documents/sse-service.js +++ b/src/renderer/store/documents/sse-service.js @@ -5,6 +5,8 @@ export default async function (id) { const keys = [R.identity, ID.tagsId] const [service, tags] = await this.store.collect(id, keys) + if (!service) return null + const document = { id, scope: ID.SSE_SERVICE,