pmtiles-swarm 0.54.2 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.55.1
11
+ ### 🐞 Bug fixes
12
+ - **0.55.0 reached new archives only.** `encoding` sits in the metadata of archives some nodes have
13
+ been serving for months, the prober learned to read it, and nothing changed — because a summary is
14
+ written into the catalog once and never questioned. Every path that re-read one was gated on the
15
+ summary being *absent*, and these summaries were present, merely old.
16
+
17
+ The summary now carries a `summaryVersion`, and one written by an older prober is re-read once, in
18
+ the background, on the next TileJSON request for that archive. Rate-limited to once a minute per
19
+ archive, as the vector-layer backfill beside it already was, and written back on any read that
20
+ produced something newer — the old early return threw away everything except vector layers, which
21
+ would have discarded the encoding it went to fetch.
22
+
23
+ This is general, not a fix for one field: raise `SUMMARY_VERSION` whenever the prober learns to
24
+ read something new, and every archive already in the catalog picks it up on its own. Nothing is
25
+ needed on an upgrade beyond asking for the archive's TileJSON, which anything using it does anyway.
26
+
27
+ ## 0.55.0
28
+ ### ✨ Features and improvements
29
+ - **The TileJSON now carries a `raster-dem` archive's `encoding`**, read out of the archive's own
30
+ metadata the same way `sparse` already is. Nothing in a PMTiles header carries this — the header
31
+ knows the tile is WebP, not what its three channels mean — so the metadata is the only place it can
32
+ come from, and without it a consumer falls back to a default that is wrong for exactly the archives
33
+ that most need to speak up: a terrarium-packed DEM read as `mapbox` decodes every mountain into
34
+ noise, silently, with a plausible-looking map on screen.
35
+
36
+ Same key and meaning as tileserver-gl, which reads `tileJSON.encoding` and accepts `terrarium` or
37
+ `mapbox`. The difference is where it comes from — there it is configuration, set per layer beside
38
+ the server; here it travels with the archive, so a mirror reads the same answer without being
39
+ configured again and a style no longer has to restate what the archive already knows. Restating it
40
+ is how a style and its data drift into disagreeing. MapLibre applies TileJSON members to the source
41
+ after construction, so this overrides an encoding written in the style, which is the intended
42
+ direction.
43
+
44
+ `custom` brings `redFactor`, `greenFactor`, `blueFactor` and `baseShift` with it, and only when all
45
+ four are present: `custom` means "the channels mean what these numbers say", so the word without
46
+ the numbers is an archive nobody can read. Anything outside the three values the style
47
+ specification defines is dropped rather than passed on — a client handed an encoding it does not
48
+ recognise is worse off than one handed nothing.
49
+
50
+ Carried through the feed as `<pmtiles:encoding>` too, so a subscriber serves elevation correctly
51
+ from the moment it joins rather than reading noise until it has probed the header itself. The
52
+ custom factors deliberately are not: an archive needing four numbers to be legible is one a mirror
53
+ should read for itself rather than take on trust from somebody else's document.
54
+
55
+ ### 🐞 Bug fixes
56
+
10
57
  ## 0.54.2
11
58
  ### ✨ Features and improvements
12
59
  - **Written down and tested: a range read is cached exactly as a tile read is**, because it is the
package/docs/tilejson.md CHANGED
@@ -16,6 +16,7 @@ page is what they mean.
16
16
  - [One string, both kinds of client](#one-string-both-kinds-of-client)
17
17
  - [Where this magnet shows up](#where-this-magnet-shows-up)
18
18
  - [`sparse`](#sparse)
19
+ - [`encoding`](#encoding)
19
20
  - [A complete example](#a-complete-example)
20
21
  - [What a plain client sees](#what-a-plain-client-sees)
21
22
 
@@ -145,6 +146,48 @@ reads the same answer rather than falling back to guessing from the tile format.
145
146
  See
146
147
  [internals.md](internals.md#answering-for-a-tile-that-is-not-there).
147
148
 
149
+ ## `encoding`
150
+
151
+ How a `raster-dem` archive packs elevation into pixels: `terrarium`, `mapbox` or
152
+ `custom`. Present only when the archive itself said so in its metadata.
153
+
154
+ Nothing in a PMTiles header carries this — the header knows the tile is WebP,
155
+ not what its three channels mean — so the metadata is the only place it can come
156
+ from, and the PMTiles specification explicitly allows metadata beyond the keys
157
+ it names. Without it a consumer falls back to a default, and the default is
158
+ wrong for exactly the archives that most need to speak up: a terrarium-packed
159
+ DEM read as `mapbox` decodes every mountain into noise, silently, with a
160
+ plausible-looking map on screen and nothing to point at.
161
+
162
+ Same key and same meaning as tileserver-gl, which reads `tileJSON.encoding` and
163
+ accepts `terrarium` or `mapbox`. The difference is where it comes from: there it
164
+ is configuration, set per layer in the server's own config, so the answer lives
165
+ beside the server rather than beside the data. Here it travels with the archive,
166
+ which is the point — a node mirroring it reads the same answer without being
167
+ configured again, and a style pointing at this TileJSON no longer has to restate
168
+ an encoding the archive already knows. Restating it is how a style and its data
169
+ drift into disagreeing.
170
+
171
+ MapLibre applies every TileJSON member to the source after the source is
172
+ constructed, so an `encoding` here **overrides** one written in the style. That
173
+ is the intended direction: the archive is the thing that knows.
174
+
175
+ Anything other than the three values the style specification defines is dropped
176
+ rather than passed on. A client handed an encoding it does not recognise is
177
+ worse off than one handed nothing, because nothing at least leaves it free to
178
+ use its own default.
179
+
180
+ ### `redFactor`, `greenFactor`, `blueFactor`, `baseShift`
181
+
182
+ Carried only alongside `encoding: "custom"`, and only when all four are present.
183
+ `custom` means "the channels mean what these numbers say", so publishing the
184
+ word without the numbers publishes an archive nobody can read — and three
185
+ numbers with one missing is no better, so the whole claim is dropped instead.
186
+
187
+ The feed carries `<pmtiles:encoding>` but not these: an archive that needs four
188
+ numbers to be legible is one a mirror should read for itself rather than take on
189
+ trust from somebody else's document.
190
+
148
191
  ## A complete example
149
192
 
150
193
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.54.2",
3
+ "version": "0.55.1",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from './sources.js';
34
34
  import { limitFor, remaining } from './seeding.js';
35
35
  import { buildTileJson, extensionMatches } from './tilejson.js';
36
+ import { SUMMARY_VERSION } from './pmtiles-probe.js';
36
37
  import { TileReadError } from './tiles.js';
37
38
 
38
39
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -363,9 +364,18 @@ export function createApp({
363
364
  * @returns {boolean} - True to attempt a read now.
364
365
  */
365
366
  const metadataRetries = new Map();
366
- const needsVectorLayers = (summary, infoHash) => {
367
- if (!summary || summary.format !== 'pbf' || summary.vectorLayers)
368
- return false;
367
+ const needsReread = (summary, infoHash) => {
368
+ if (!summary) return false;
369
+ // Two reasons to go back to the archive. The first is the one this began
370
+ // as: a vector archive whose layers have not been read yet. The second is
371
+ // the general case it turned out to be — a summary written by an older
372
+ // prober, missing whatever that prober did not know to look for. Without
373
+ // it, adding a field to the summary reaches new archives only, and every
374
+ // archive already in the catalog keeps its hole until somebody removes and
375
+ // re-adds it by hand.
376
+ const stale = summary.summaryVersion !== SUMMARY_VERSION;
377
+ const missingLayers = summary.format === 'pbf' && !summary.vectorLayers;
378
+ if (!stale && !missingLayers) return false;
369
379
  const last = metadataRetries.get(infoHash) ?? 0;
370
380
  if (Date.now() - last < 60000) return false;
371
381
  metadataRetries.set(infoHash, Date.now());
@@ -388,13 +398,18 @@ export function createApp({
388
398
  // .torrent endpoints work without one. Enriching a summary is the least
389
399
  // important thing here and must never be what takes a reply down.
390
400
  if (typeof tiles?.summarize !== 'function') return;
391
- if (!needsVectorLayers(entry.pmtiles, entry.infoHash)) return;
401
+ if (!needsReread(entry.pmtiles, entry.infoHash)) return;
392
402
 
393
403
  const timeoutMs = config.tiles?.metadataTimeoutMs ?? 120000;
394
404
  tiles
395
405
  .summarize(entry.infoHash, { timeoutMs })
396
406
  .then(async (summary) => {
397
- if (!summary.vectorLayers) return;
407
+ // Written back whenever the read produced something newer than what
408
+ // was stored. Returning early unless vector layers turned up was right
409
+ // when layers were the only thing this looked for; it would now throw
410
+ // away the encoding it went to fetch.
411
+ const fresh = summary.summaryVersion !== entry.pmtiles?.summaryVersion;
412
+ if (!summary.vectorLayers && !fresh) return;
398
413
  await catalog.put({
399
414
  infoHash: entry.infoHash,
400
415
  pmtiles: { ...entry.pmtiles, ...summary },
package/src/feed.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { mutableMagnet } from './mutable.js';
21
+ import { metadataEncoding } from './pmtiles-probe.js';
21
22
 
22
23
  const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
23
24
 
@@ -119,6 +120,14 @@ function renderItem(entry, baseUrl) {
119
120
  map.attribution
120
121
  ? ` <pmtiles:attribution>${xml(map.attribution)}</pmtiles:attribution>`
121
122
  : '',
123
+ // So a subscriber can serve elevation correctly from the moment it
124
+ // joins, rather than reading noise until it has probed the header
125
+ // itself. The custom factors are deliberately not carried: an archive
126
+ // that needs four numbers to be legible is one a mirror should read
127
+ // for itself rather than take on trust from a feed.
128
+ map.encoding
129
+ ? ` <pmtiles:encoding>${xml(map.encoding)}</pmtiles:encoding>`
130
+ : '',
122
131
  ]
123
132
  .filter(Boolean)
124
133
  .join('\n')
@@ -233,6 +242,11 @@ function mapSummary(block) {
233
242
  bounds: bounds.length === 4 ? bounds : undefined,
234
243
  tileCount: number('pmtiles:tiles'),
235
244
  attribution: tag(block, 'pmtiles:attribution'),
245
+ // Carried because a subscriber serves tiles from this summary before it
246
+ // has read a byte of the archive, and for a raster-dem archive the
247
+ // encoding is the difference between elevation and noise. Validated on the
248
+ // way in, since a feed is somebody else's document.
249
+ encoding: metadataEncoding(tag(block, 'pmtiles:encoding')),
236
250
  // Says where this came from, because a summary taken on trust from another
237
251
  // node is not the same fact as one read off the archive's own header, and
238
252
  // the difference matters when the two disagree.
@@ -58,6 +58,49 @@ export function metadataFlag(value) {
58
58
  return undefined;
59
59
  }
60
60
 
61
+ /**
62
+ * What a `raster-dem` archive says about how its elevations are packed.
63
+ *
64
+ * Nothing in a PMTiles header says this — the header knows the tile is WebP,
65
+ * not what the three channels mean — so the only place it can come from is the
66
+ * archive's own metadata. Without it a consumer falls back to a default, and
67
+ * the default is wrong for exactly the archives that most need to say
68
+ * something: a terrarium-packed DEM read as `mapbox` decodes every mountain
69
+ * into noise, silently, with a plausible-looking map on screen.
70
+ *
71
+ * The values are the ones the style specification defines, and anything else
72
+ * is dropped rather than passed on. A tile client handed an encoding it does
73
+ * not recognise is worse off than one handed nothing, because nothing at least
74
+ * leaves it free to use its own default.
75
+ * @param {unknown} value - Whatever the metadata held.
76
+ * @returns {string | undefined} - A known encoding, or undefined.
77
+ */
78
+ export function metadataEncoding(value) {
79
+ if (typeof value !== 'string') return undefined;
80
+ const text = value.trim().toLowerCase();
81
+ return ['terrarium', 'mapbox', 'custom'].includes(text) ? text : undefined;
82
+ }
83
+
84
+ /**
85
+ * The numbers a `custom` encoding is meaningless without.
86
+ *
87
+ * `encoding: "custom"` says "the channels mean what these four factors say",
88
+ * so carrying the word and not the factors publishes an archive nobody can
89
+ * read. They travel together or not at all.
90
+ * @param {object} metadata - The archive's metadata.
91
+ * @returns {object | undefined} - The four factors, or undefined.
92
+ */
93
+ export function customEncodingFactors(metadata) {
94
+ const named = ['redFactor', 'greenFactor', 'blueFactor', 'baseShift'];
95
+ const out = {};
96
+ for (const name of named) {
97
+ const value = Number(metadata[name]);
98
+ if (!Number.isFinite(value)) return undefined;
99
+ out[name] = value;
100
+ }
101
+ return out;
102
+ }
103
+
61
104
  /**
62
105
  * Reads an archive's header and metadata.
63
106
  *
@@ -67,6 +110,24 @@ export function metadataFlag(value) {
67
110
  * @param {string} location - Local path or http(s) URL.
68
111
  * @returns {Promise<PMTilesSummary>} - The summary.
69
112
  */
113
+ /**
114
+ * What this prober reads, as a number that goes up when that changes.
115
+ *
116
+ * A summary is stored in the catalog and never read again, which is right --
117
+ * re-reading a header out of the swarm is not free, and the answer does not
118
+ * change for a given infohash. It goes wrong the moment the prober learns to
119
+ * read something new: every archive probed before that keeps a summary with a
120
+ * hole in it, for ever, and the only way out was to remove and re-add it.
121
+ *
122
+ * `encoding` was the case that made this obvious. It sat in the metadata of
123
+ * archives this node had been serving for months, and adding the code to read
124
+ * it changed nothing at all, because nothing ever asked again.
125
+ *
126
+ * So: stamp what the prober knew at the time, and re-read once when that is
127
+ * behind. Raise this whenever a field is added to the summary below.
128
+ */
129
+ export const SUMMARY_VERSION = 2;
130
+
70
131
  export function summarize(header, metadata = {}) {
71
132
  const type = TILE_TYPES[header.tileType] ?? TILE_TYPES[0];
72
133
 
@@ -80,6 +141,7 @@ export function summarize(header, metadata = {}) {
80
141
  );
81
142
 
82
143
  return {
144
+ summaryVersion: SUMMARY_VERSION,
83
145
  specVersion: header.specVersion,
84
146
  format: type.format,
85
147
  contentType: type.contentType,
@@ -103,6 +165,16 @@ export function summarize(header, metadata = {}) {
103
165
  // the same key, so an archive built to be served there carries the answer
104
166
  // with it and does not have to be configured again here.
105
167
  sparse: metadataFlag(metadata.sparse),
168
+ // How a raster-dem archive packs elevation into pixels: terrarium, mapbox
169
+ // or custom. Read from the archive rather than configured here, for the
170
+ // same reason `sparse` is — the archive knows, and a mirror of it should
171
+ // not have to be told again.
172
+ encoding: metadataEncoding(metadata.encoding),
173
+ // Only where the encoding is custom, since they mean nothing otherwise.
174
+ encodingFactors:
175
+ metadataEncoding(metadata.encoding) === 'custom'
176
+ ? customEncodingFactors(metadata)
177
+ : undefined,
106
178
  };
107
179
  }
108
180
 
package/src/tilejson.js CHANGED
@@ -55,6 +55,16 @@ export function buildTileJson(entry, baseUrl) {
55
55
  // Passed on so the next node to mirror this archive reads the same answer we
56
56
  // did, rather than falling back to a guess from the tile format.
57
57
  if (summary.sparse !== undefined) doc.sparse = summary.sparse;
58
+ // How to read the pixels of a raster-dem archive. tileserver-gl reads the
59
+ // same key, so an archive built to be served there carries the answer with
60
+ // it — and a style pointing at this TileJSON no longer has to restate an
61
+ // encoding that the archive already knows, which is how a style and its data
62
+ // drift into disagreeing.
63
+ if (summary.encoding) doc.encoding = summary.encoding;
64
+ // `custom` is unreadable without them, so they go wherever it does.
65
+ if (summary.encoding === 'custom' && summary.encodingFactors) {
66
+ Object.assign(doc, summary.encodingFactors);
67
+ }
58
68
  if (summary.format === 'pbf') doc.format = 'pbf';
59
69
  else if (summary.format) doc.format = summary.format;
60
70