pmtiles-swarm 0.97.0 → 0.98.0

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,35 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.98.0
11
+ ### ✨ Features and improvements
12
+ - **A finished MBTiles archive works as a stack layer, and now says what it holds.** It always read:
13
+ the tile store opens one from a complete local copy, and a stack asks the store for tiles like
14
+ anything else. What it could not do was describe itself. Only PMTiles was ever probed for a
15
+ summary — right when only PMTiles could be served, and never revisited once MBTiles became
16
+ servable — so those entries carried none, and a summary is what `stackCoverage` reads. A stack
17
+ naming one advertised the fallbacks in its TileJSON, z0–z14 over the whole world, however narrow
18
+ the archive actually was. Nothing refused it and nothing warned: the recipe was valid, the tiles
19
+ were right, and only the coverage was a fiction.
20
+
21
+ Both formats that can be served are read for a summary now, through the same summariser —
22
+ `probeMbtiles` off the adapter's `getHeader` and `getMetadata`, which is what that adapter exists
23
+ for. Most of the MBTiles spec is optional, so where an archive states nothing the reader derives
24
+ what it can: the zoom range comes from the tiles table when `minzoom` and `maxzoom` are absent.
25
+
26
+ The metadata reader also carries `encoding` through, with the four factors a custom packing is
27
+ unreadable without. An elevation stack decodes the pixels rather than passing them through, so an
28
+ archive that lost its encoding was not a tile that failed but a tile of wrong heights.
29
+
30
+ Archives already in the catalog are summarised by the head warmer on its next pass, once complete
31
+ — an MBTiles has no head to pull out of a swarm, so there is nothing to be due until the file is
32
+ whole, and then the read is local and instant. Unchanged: `/latest/<category>` still offers no
33
+ tile endpoint for an MBTiles, because a category is a promise to every node and this one is only
34
+ keepable on a node holding the whole file. See [tile-stacks.md](docs/tile-stacks.md) — "An
35
+ MBTiles archive as a source".
36
+
37
+ ### 🐞 Bug fixes
38
+
10
39
  ## 0.97.0
11
40
  ### ✨ Features and improvements
12
41
  - **A stack can be told to forget what it has merged, and forgets it by itself when its recipe
@@ -29,6 +29,7 @@ of its parts.
29
29
  - [Painting order](#painting-order)
30
30
  - [The two pixel spaces](#the-two-pixel-spaces)
31
31
  - [Naming a source](#naming-a-source)
32
+ - [An MBTiles archive as a source](#an-mbtiles-archive-as-a-source)
32
33
  - [The config file](#the-config-file)
33
34
  - [Translating a rio-rgbify-merge config](#translating-a-rio-rgbify-merge-config)
34
35
  - [Evaluating one tile](#evaluating-one-tile)
@@ -174,6 +175,35 @@ something else.
174
175
  A stack may mix the two. Whether _any_ source is category-resolved decides the
175
176
  whole stack's caching headers, below.
176
177
 
178
+ ### An MBTiles archive as a source
179
+
180
+ Either form may name an MBTiles archive, with one condition: **this node must
181
+ hold the complete file.** MBTiles is SQLite, whose pages are scattered rather
182
+ than spatially clustered, so it cannot be read a byte range at a time out of a
183
+ swarm the way PMTiles can — a source still arriving answers 503, and no amount
184
+ of waiting for the right pieces changes that. A stack with an MBTiles source is
185
+ therefore only servable on nodes that have finished downloading it.
186
+
187
+ Everything else is the same. It resolves, merges, clips, feathers and bakes
188
+ like any other source, because the tile store presents it through the same
189
+ three methods a PMTiles archive answers.
190
+
191
+ What it covers comes from the metadata table, read at import by `probeMbtiles`
192
+ and summarised by the same `summarize` the PMTiles path uses. Most of the
193
+ MBTiles spec is optional, so where an archive states nothing the reader derives
194
+ what it can — the zoom range comes from the tiles table itself when `minzoom`
195
+ and `maxzoom` are absent, and bounds default to the whole world.
196
+
197
+ Two keys outside the spec are read, both because this is where every tool that
198
+ needs them puts them: `sparse`, which tileserver-gl reads the same way, and
199
+ `encoding` — with `redFactor`, `greenFactor`, `blueFactor` and `baseShift` for
200
+ a custom packing. An elevation stack decodes the pixels rather than passing
201
+ them through, so an archive that loses its encoding is not a tile that fails but
202
+ a tile of wrong heights.
203
+
204
+ An archive already in the catalog from before any of this was probed is
205
+ summarised by the head warmer on its next pass, once it is complete.
206
+
177
207
  ## The config file
178
208
 
179
209
  Stacks live in `data/stacks.json`, beside `data/catalog.json` and for the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
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/library.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import { publishingBase, publishingFor, reachability } from './catalog.js';
15
15
  import { linkLatest } from './latest-link.js';
16
16
  import { checkOrigin, fingerprintOrigin } from './origin.js';
17
+ import { probeMbtiles } from './mbtiles.js';
17
18
  import { probePMTiles } from './pmtiles-probe.js';
18
19
  import { archiveDirName } from './savepath.js';
19
20
  import {
@@ -303,11 +304,12 @@ export class Library {
303
304
  });
304
305
 
305
306
  try {
306
- // Only PMTiles can have its tiles served, so only PMTiles gets probed.
307
- const summary =
308
- identified.kind === 'pmtiles'
309
- ? await probePMTiles(absolute).catch(() => undefined)
310
- : undefined;
307
+ // Both formats that can be served get read for one. The kind comes from
308
+ // the content rather than the extension, which is what identify exists
309
+ // for.
310
+ const summary = await probeArchive(absolute, identified.kind).catch(
311
+ () => undefined,
312
+ );
311
313
 
312
314
  const created = await createTorrentFromFile(absolute, {
313
315
  creator: this.#creator(),
@@ -967,6 +969,11 @@ export class Library {
967
969
  // progress through runningAdds() and says "watch the log" as it closes.
968
970
  options.onValidated?.({ url, kind: identified.kind });
969
971
 
972
+ // PMTiles only, and not an oversight: this reads the archive over range
973
+ // requests before anything has been downloaded, and there is no partial
974
+ // read of a SQLite file that answers anything. An MBTiles mirrored from a
975
+ // URL is summarised by the warmer once the download finishes, which is the
976
+ // first moment it can be.
970
977
  const summary =
971
978
  identified.kind === 'pmtiles'
972
979
  ? await probePMTiles(url).catch(() => undefined)
@@ -1605,8 +1612,9 @@ export class Library {
1605
1612
  // facts, and probing on the second one's behalf would hang.
1606
1613
  let summary;
1607
1614
  if (readable && torrent.progress === 1 && torrent.savePath) {
1608
- summary = await probePMTiles(
1615
+ summary = await probeArchive(
1609
1616
  path.join(torrent.savePath, torrent.name),
1617
+ guessKind(torrent.name ?? ''),
1610
1618
  ).catch(() => undefined);
1611
1619
  }
1612
1620
 
@@ -3383,6 +3391,30 @@ export function guessKind(name) {
3383
3391
  return undefined;
3384
3392
  }
3385
3393
 
3394
+ /**
3395
+ * Reads the summary out of whichever kind of archive this is.
3396
+ *
3397
+ * Only PMTiles was ever probed, which was right when only PMTiles could be
3398
+ * served. MBTiles became servable from a complete local copy and this did not
3399
+ * follow, so those entries carried no summary at all -- and a summary is what
3400
+ * everything downstream reads coverage from. A stack naming one advertised the
3401
+ * fallbacks in its TileJSON, z0-z14 over the whole world, rather than the zoom
3402
+ * range and bounds the archive states about itself.
3403
+ *
3404
+ * Anything else is left alone rather than guessed at. A .osm.pbf being mirrored
3405
+ * has no summary to read and asking for one is how it ends up reported as a
3406
+ * broken map instead of a file being distributed.
3407
+ * @param {string} location - Local path, or a URL for PMTiles.
3408
+ * @param {string} [kind] - What it was identified as; the name decides if not.
3409
+ * @returns {Promise<object|undefined>} - The summary, where there is one.
3410
+ */
3411
+ export async function probeArchive(location, kind) {
3412
+ const format = kind ?? guessKind(location);
3413
+ if (format === 'mbtiles') return probeMbtiles(location);
3414
+ if (format === 'pmtiles') return probePMTiles(location);
3415
+ return undefined;
3416
+ }
3417
+
3386
3418
  /**
3387
3419
  * Moves a file, whether or not the two paths share a filesystem.
3388
3420
  *
package/src/mbtiles.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { summarize } from './pmtiles-probe.js';
2
3
 
3
4
  /**
4
5
  * Reading tiles out of a completed MBTiles archive.
@@ -199,6 +200,17 @@ export class MbtilesArchive {
199
200
  // Same key tileserver-gl reads, carried through so an MBTiles archive
200
201
  // gets the same treatment a PMTiles one does.
201
202
  sparse: meta.sparse,
203
+ // How heights are packed into the pixels, and the four factors a custom
204
+ // packing is unreadable without. Not in the MBTiles spec -- neither is
205
+ // `sparse` -- but this is where every tool that needs it puts it, and a
206
+ // terrain archive that loses it is decoded as whatever the reader
207
+ // assumes. That is not a tile that fails; it is a tile of wrong heights,
208
+ // which is worse.
209
+ encoding: meta.encoding,
210
+ redFactor: meta.redFactor,
211
+ greenFactor: meta.greenFactor,
212
+ blueFactor: meta.blueFactor,
213
+ baseShift: meta.baseShift,
202
214
  };
203
215
  }
204
216
 
@@ -254,3 +266,32 @@ export class MbtilesArchive {
254
266
  }
255
267
  }
256
268
  }
269
+
270
+ /**
271
+ * Reads an MBTiles archive's summary, the way probePMTiles reads a PMTiles one.
272
+ *
273
+ * The same summariser, off the same two methods -- which is the whole point of
274
+ * the adapter above: MBTiles keeps in a metadata table what PMTiles keeps in a
275
+ * fixed header, and everything upstream of `summarize` should not have to know
276
+ * which it was reading. `getHeader` even derives the zoom range from the tiles
277
+ * table where the metadata omits it, so a summary comes back for an archive
278
+ * that declares almost nothing about itself.
279
+ *
280
+ * Local paths only, and not by oversight. probePMTiles takes a URL because
281
+ * PMTiles is read a byte range at a time, and SQLite is not: there is no
282
+ * partial read of an MBTiles that answers anything, which is the same reason
283
+ * it is served only from a complete local copy.
284
+ * @param {string} file - Path to the .mbtiles file.
285
+ * @returns {Promise<object>} - The summary, in the shape the catalog keeps.
286
+ */
287
+ export async function probeMbtiles(file) {
288
+ const archive = await MbtilesArchive.open(file);
289
+ try {
290
+ return summarize(
291
+ await archive.getHeader(),
292
+ (await archive.getMetadata()) ?? {},
293
+ );
294
+ } finally {
295
+ archive.close();
296
+ }
297
+ }
package/src/prewarm.js CHANGED
@@ -156,7 +156,14 @@ export class HeadWarmer {
156
156
  // not, since an archive joined by magnet has no kind until its metadata
157
157
  // arrives.
158
158
  const kind = entry.kind ?? guessKind(entry.name ?? '');
159
- if (kind !== 'pmtiles') return false;
159
+ if (kind !== 'pmtiles' && kind !== 'mbtiles') return false;
160
+
161
+ // MBTiles has no head to pull out of the swarm -- it is SQLite, read whole
162
+ // or not at all -- so there is nothing here to be due until the file is.
163
+ // Once it is, the read is local and instant, and it is the only thing that
164
+ // gives an archive already in the catalog the summary this did not use to
165
+ // record for it.
166
+ if (kind === 'mbtiles' && entry.complete !== true) return false;
160
167
 
161
168
  // A summary is only an answer about *this disk* if a header on this disk
162
169
  // produced it. `format` was standing in for that and does not mean it.
@@ -289,6 +296,11 @@ export class HeadWarmer {
289
296
  `[warm] ${entry.name}: header read; its metadata is at the far end ` +
290
297
  'of the archive and has not arrived yet',
291
298
  );
299
+ } else if ((entry.kind ?? guessKind(entry.name ?? '')) === 'mbtiles') {
300
+ // Not a header: MBTiles keeps in a metadata table what PMTiles keeps
301
+ // in a fixed header, and a log saying otherwise sends whoever reads it
302
+ // looking for the wrong thing.
303
+ console.log(`[warm] ${entry.name}: metadata read`);
292
304
  } else {
293
305
  console.log(`[warm] ${entry.name}: header read`);
294
306
  }