pmtiles-swarm 0.98.0 β†’ 0.98.2

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,41 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.98.2
11
+ ### ✨ Features and improvements
12
+
13
+ ### 🐞 Bug fixes
14
+ - **The console called a complete MBTiles archive "not servable".** It is, and has been since the
15
+ tile store learned to open one: PMTiles always, MBTiles from a complete local copy. The console
16
+ never followed. It tested `kind === 'pmtiles'` in two places, so an MBTiles that had finished
17
+ hashing was labelled `mbtiles Β· not servable` in the list and had its TileJSON, preview and tile
18
+ endpoint hidden in the panel β€” all of which the node was answering perfectly well.
19
+
20
+ One rule now, matching what the tile route actually enforces, and an archive still arriving reads
21
+ as `servable once complete` rather than as one that never will be. Warming stays PMTiles-only: it
22
+ fetches the pieces a region's tiles live in, and an MBTiles is read whole or not at all, so there
23
+ is never a region of one to warm.
24
+
25
+ ## 0.98.1
26
+ ### ✨ Features and improvements
27
+ - **The two archive readers no longer import each other.** Summarising an MBTiles archive reached
28
+ into `pmtiles-probe.js` for `summarize`, which put one format's reader inside the other's module
29
+ and dragged the `pmtiles` package in behind it. The half neither format owns β€” the summary shape,
30
+ its version, the metadata readers and the tile-type table β€” is now `archive-summary.js`, which
31
+ imports nothing and opens nothing: it takes a header and a metadata document and returns a
32
+ summary. `pmtiles-probe.js` keeps the PMTiles half and `mbtiles.js` the SQLite half, and neither
33
+ reaches the other.
34
+
35
+ Nothing about what the node serves changes; this is where the code lives.
36
+
37
+ ### 🐞 Bug fixes
38
+ - **An MBTiles archive holding MLT tiles was summarised as an unknown format.** The table mapping a
39
+ format name onto a tile type had been written twice, in opposite directions, and the copy beside
40
+ the MBTiles reader was written by hand and had no entry for `mlt` β€” so an archive declaring it
41
+ came back as `unknown`, which is what the catalog then reported and what a stack over it read.
42
+ There is one table now, with the reverse lookup derived from it, so the two halves cannot drift
43
+ apart again.
44
+
10
45
  ## 0.98.0
11
46
  ### ✨ Features and improvements
12
47
  - **A finished MBTiles archive works as a stack layer, and now says what it holds.** It always read:
@@ -469,9 +469,11 @@ This is the one thing that stops a stack being a small feature, and it is
469
469
  narrower than "the node cannot handle tiles". It already does, in most of the
470
470
  ways a stack needs:
471
471
 
472
- - `identify.js` reads an archive's magic bytes; the prober reads PMTiles
473
- headers, directories and metadata; `mbtiles.js` queries tile rows out of
474
- SQLite.
472
+ - `identify.js` reads an archive's magic bytes; `pmtiles-probe.js` reads PMTiles
473
+ headers, directories and metadata; `mbtiles.js` queries tile rows and the
474
+ metadata table out of SQLite. Neither format's reader imports the other's:
475
+ what they share is `archive-summary.js`, which turns a header and a metadata
476
+ document into the summary the catalog keeps and opens nothing itself.
475
477
  - `TileStore.getTile` resolves an archive, reads a tile through the local file
476
478
  or the swarm, and knows its format from the header rather than by guessing.
477
479
  - The tile route already gzips vector tiles through `node:zlib`, abandons a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.98.0",
3
+ "version": "0.98.2",
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
@@ -38,7 +38,7 @@ import {
38
38
  } from './sources.js';
39
39
  import { limitFor, remaining } from './seeding.js';
40
40
  import { buildTileJson, extensionMatches, tileExtension } from './tilejson.js';
41
- import { SUMMARY_VERSION } from './pmtiles-probe.js';
41
+ import { SUMMARY_VERSION } from './archive-summary.js';
42
42
  import { TileReadError } from './tiles.js';
43
43
  import { loadCodec } from './codec.js';
44
44
  import { answerStackTile, outputFormat, outputSize } from './stack-tile.js';
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The summary this project keeps about a map archive, and the vocabulary for
3
+ * building one.
4
+ *
5
+ * What a subscriber needs in order to decide whether they want an archive:
6
+ * coverage, zoom range, tile type, size. This is what makes a map RSS feed more
7
+ * useful than a generic torrent feed β€” an item can say "raster webp, z0-14,
8
+ * covering Switzerland, 36 GiB" instead of just a filename, so a consumer can
9
+ * filter before committing to a download.
10
+ *
11
+ * Here rather than beside either reader because both formats produce one, and
12
+ * neither should have to import the other to do it. Nothing in this file opens
13
+ * a file or a socket: it takes a header and a metadata document and returns the
14
+ * summary, which is why it can be shared without dragging one format's
15
+ * dependencies into the other's module.
16
+ */
17
+
18
+ /**
19
+ * Maps tile type numbers onto names and content types.
20
+ *
21
+ * The numbering is PMTiles', because a number was needed and that one is
22
+ * written down. An MBTiles archive names its format in a metadata row instead,
23
+ * and `tileTypeFor` is how it says the same thing in the same terms.
24
+ */
25
+ const TILE_TYPES = {
26
+ 0: { format: 'unknown', contentType: 'application/octet-stream' },
27
+ 1: { format: 'pbf', contentType: 'application/x-protobuf' },
28
+ 2: { format: 'png', contentType: 'image/png' },
29
+ 3: { format: 'jpeg', contentType: 'image/jpeg' },
30
+ 4: { format: 'webp', contentType: 'image/webp' },
31
+ 5: { format: 'avif', contentType: 'image/avif' },
32
+ // No MIME is registered for MLT, and nothing selects a tile by content type.
33
+ 6: { format: 'mlt', contentType: 'application/octet-stream' },
34
+ };
35
+
36
+ /**
37
+ * The tile type number for a format an archive names in words.
38
+ *
39
+ * The inverse of the table above, kept beside it so the two halves cannot
40
+ * drift. `mvt` and `jpg` are spellings the same formats appear under in the
41
+ * wild; anything unrecognised is 0, which reads back as "unknown" rather than
42
+ * as a format nothing can serve.
43
+ * @param {string} [format] - What the archive calls its tiles.
44
+ * @returns {number} - The tile type number.
45
+ */
46
+ export function tileTypeFor(format) {
47
+ const named = String(format ?? '').toLowerCase();
48
+ const found = Object.entries(TILE_TYPES).find(
49
+ ([, type]) => type.format === named,
50
+ );
51
+ if (found) return Number(found[0]);
52
+ return { mvt: 1, jpg: 3 }[named] ?? 0;
53
+ }
54
+
55
+ /**
56
+ * Summary of an archive, as stored in the catalog and published in the feed.
57
+ * @typedef {object} ArchiveSummary
58
+ * @property {number} specVersion - PMTiles spec version.
59
+ * @property {string} format - Tile format: pbf, png, jpeg, webp, avif.
60
+ * @property {string} contentType - Matching content type.
61
+ * @property {number} minZoom - Lowest zoom present.
62
+ * @property {number} maxZoom - Highest zoom present.
63
+ * @property {number[]} bounds - [minLon, minLat, maxLon, maxLat].
64
+ * @property {number[]} center - [lon, lat, zoom].
65
+ * @property {number} tileCount - Addressed tile count.
66
+ * @property {boolean} clustered - Whether tiles are stored in Hilbert order.
67
+ * @property {string} [name] - Name from the archive metadata.
68
+ * @property {string} [description] - Description from the archive metadata.
69
+ * @property {string} [attribution] - Attribution from the archive metadata.
70
+ * @property {object[]} [vectorLayers] - Vector layer definitions, for pbf archives.
71
+ * @property {boolean} [sparse] - What the archive says about missing tiles, if it says anything.
72
+ */
73
+
74
+ /**
75
+ * Reads a flag out of archive metadata, which is not reliably typed.
76
+ *
77
+ * A PMTiles JSON blob carries a real boolean, but the same metadata routinely
78
+ * arrives having been round-tripped through MBTiles, where every value is TEXT
79
+ * β€” so the honest reading of `"false"` is false, not "a non-empty string".
80
+ * @param {unknown} value - Whatever the metadata held.
81
+ * @returns {boolean | undefined} - The flag, or undefined if it said nothing.
82
+ */
83
+ export function metadataFlag(value) {
84
+ if (value === undefined || value === null || value === '') return undefined;
85
+ if (typeof value === 'boolean') return value;
86
+ if (typeof value === 'number') return value !== 0;
87
+ const text = String(value).trim().toLowerCase();
88
+ if (['true', '1', 'yes'].includes(text)) return true;
89
+ if (['false', '0', 'no'].includes(text)) return false;
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * The tile encoding an archive declares, if it is one MapLibre understands.
95
+ *
96
+ * See docs/tilejson.md β€” `encoding`.
97
+ * @param {unknown} value - Whatever the metadata held.
98
+ * @returns {string | undefined} - A known encoding, or undefined.
99
+ */
100
+ export function metadataEncoding(value) {
101
+ if (typeof value !== 'string') return undefined;
102
+ const text = value.trim().toLowerCase();
103
+ // An unrecognised value is dropped: it would cost a client its own default.
104
+ return ['terrarium', 'mapbox', 'custom', 'mlt'].includes(text)
105
+ ? text
106
+ : undefined;
107
+ }
108
+
109
+ /**
110
+ * The four factors a `custom` encoding is unreadable without. All or nothing.
111
+ * @param {object} metadata - The archive's metadata.
112
+ * @returns {object | undefined} - The four factors, or undefined.
113
+ */
114
+ export function customEncodingFactors(metadata) {
115
+ const named = ['redFactor', 'greenFactor', 'blueFactor', 'baseShift'];
116
+ const out = {};
117
+ for (const name of named) {
118
+ const value = Number(metadata[name]);
119
+ if (!Number.isFinite(value)) return undefined;
120
+ out[name] = value;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * What this prober reads. Raise it whenever a field is added to the summary,
127
+ * and archives probed by an older build are re-read once. See
128
+ * docs/internals.md β€” "Re-reading a summary an older prober wrote".
129
+ */
130
+ export const SUMMARY_VERSION = 3;
131
+
132
+ /**
133
+ * What the catalog keeps about an archive, from its header and metadata.
134
+ * @param {object} header - A parsed PMTiles v3 header.
135
+ * @param {object} [metadata] - The archive's own metadata document.
136
+ * @returns {object} - The summary, at `SUMMARY_VERSION`.
137
+ */
138
+ export function summarize(header, metadata = {}) {
139
+ const type = TILE_TYPES[header.tileType] ?? TILE_TYPES[0];
140
+
141
+ // An archive with no bounds set reports all zeroes; treat that as global
142
+ // rather than as a point at null island.
143
+ const hasBounds = !(
144
+ header.minLon === 0 &&
145
+ header.minLat === 0 &&
146
+ header.maxLon === 0 &&
147
+ header.maxLat === 0
148
+ );
149
+
150
+ return {
151
+ summaryVersion: SUMMARY_VERSION,
152
+ specVersion: header.specVersion,
153
+ format: type.format,
154
+ contentType: type.contentType,
155
+ minZoom: header.minZoom,
156
+ maxZoom: header.maxZoom,
157
+ bounds: hasBounds
158
+ ? [header.minLon, header.minLat, header.maxLon, header.maxLat]
159
+ : [-180, -85.051129, 180, 85.051129],
160
+ center: [
161
+ header.centerLon,
162
+ header.centerLat,
163
+ header.centerZoom || Math.round(header.maxZoom / 2),
164
+ ],
165
+ tileCount: header.numAddressedTiles,
166
+ clustered: header.clustered,
167
+ name: metadata.name,
168
+ description: metadata.description,
169
+ attribution: metadata.attribution,
170
+ vectorLayers: metadata.vector_layers,
171
+ // What the archive says about its own missing tiles. tileserver-gl reads
172
+ // the same key, so an archive built to be served there carries the answer
173
+ // with it and does not have to be configured again here.
174
+ sparse: metadataFlag(metadata.sparse),
175
+ // The header settles MLT; only the metadata can settle elevation packing.
176
+ encoding:
177
+ type.format === 'mlt' ? 'mlt' : metadataEncoding(metadata.encoding),
178
+ encodingFactors:
179
+ metadataEncoding(metadata.encoding) === 'custom'
180
+ ? customEncodingFactors(metadata)
181
+ : undefined,
182
+ };
183
+ }
package/src/feed.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { mutableMagnet } from './mutable.js';
21
- import { metadataEncoding } from './pmtiles-probe.js';
21
+ import { metadataEncoding } from './archive-summary.js';
22
22
 
23
23
  const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
24
24
 
package/src/mbtiles.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { summarize } from './pmtiles-probe.js';
2
+ import { summarize, tileTypeFor } from './archive-summary.js';
3
3
 
4
4
  /**
5
5
  * Reading tiles out of a completed MBTiles archive.
@@ -21,17 +21,6 @@ import { summarize } from './pmtiles-probe.js';
21
21
  * MBTiles 1.3: https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md
22
22
  */
23
23
 
24
- /** Maps an MBTiles `format` onto the PMTiles tile-type number summarize expects. */
25
- const TILE_TYPES = {
26
- pbf: 1,
27
- mvt: 1,
28
- png: 2,
29
- jpg: 3,
30
- jpeg: 3,
31
- webp: 4,
32
- avif: 5,
33
- };
34
-
35
24
  /** The whole world, for an archive that declares no bounds. */
36
25
  const WHOLE_WORLD = [-180, -85.051129, 180, 85.051129];
37
26
 
@@ -166,7 +155,7 @@ export class MbtilesArchive {
166
155
  return {
167
156
  // MBTiles has no equivalent, and nothing reads this except to report it.
168
157
  specVersion: 3,
169
- tileType: TILE_TYPES[format] ?? 0,
158
+ tileType: tileTypeFor(format),
170
159
  minZoom,
171
160
  maxZoom,
172
161
  minLon: bounds[0],
@@ -1,97 +1,15 @@
1
1
  import { FetchSource, PMTiles } from 'pmtiles';
2
+ import { summarize } from './archive-summary.js';
2
3
  import { NodeFileSource } from './file-source.js';
3
4
 
4
5
  /**
5
- * Reads the facts about an archive that a subscriber needs in order to decide
6
- * whether they want it: coverage, zoom range, tile type, size.
6
+ * Reading a PMTiles archive's summary, from a local path or over HTTP.
7
7
  *
8
- * This is what makes a map RSS feed more useful than a generic torrent feed β€”
9
- * an item can say "raster webp, z0-14, covering Switzerland, 36 GiB" instead of
10
- * just a filename, so a consumer can filter before committing to a download.
8
+ * The summary itself is built in archive-summary.js, which knows nothing about
9
+ * either format. What is here is the half that is PMTiles: opening one, and
10
+ * where its header and metadata are kept.
11
11
  */
12
12
 
13
- /** Maps PMTiles tile type numbers onto names and content types. */
14
- const TILE_TYPES = {
15
- 0: { format: 'unknown', contentType: 'application/octet-stream' },
16
- 1: { format: 'pbf', contentType: 'application/x-protobuf' },
17
- 2: { format: 'png', contentType: 'image/png' },
18
- 3: { format: 'jpeg', contentType: 'image/jpeg' },
19
- 4: { format: 'webp', contentType: 'image/webp' },
20
- 5: { format: 'avif', contentType: 'image/avif' },
21
- // No MIME is registered for MLT, and nothing selects a tile by content type.
22
- 6: { format: 'mlt', contentType: 'application/octet-stream' },
23
- };
24
-
25
- /**
26
- * Summary of an archive, as stored in the catalog and published in the feed.
27
- * @typedef {object} PMTilesSummary
28
- * @property {number} specVersion - PMTiles spec version.
29
- * @property {string} format - Tile format: pbf, png, jpeg, webp, avif.
30
- * @property {string} contentType - Matching content type.
31
- * @property {number} minZoom - Lowest zoom present.
32
- * @property {number} maxZoom - Highest zoom present.
33
- * @property {number[]} bounds - [minLon, minLat, maxLon, maxLat].
34
- * @property {number[]} center - [lon, lat, zoom].
35
- * @property {number} tileCount - Addressed tile count.
36
- * @property {boolean} clustered - Whether tiles are stored in Hilbert order.
37
- * @property {string} [name] - Name from the archive metadata.
38
- * @property {string} [description] - Description from the archive metadata.
39
- * @property {string} [attribution] - Attribution from the archive metadata.
40
- * @property {object[]} [vectorLayers] - Vector layer definitions, for pbf archives.
41
- * @property {boolean} [sparse] - What the archive says about missing tiles, if it says anything.
42
- */
43
-
44
- /**
45
- * Reads a flag out of archive metadata, which is not reliably typed.
46
- *
47
- * A PMTiles JSON blob carries a real boolean, but the same metadata routinely
48
- * arrives having been round-tripped through MBTiles, where every value is TEXT
49
- * β€” so the honest reading of `"false"` is false, not "a non-empty string".
50
- * @param {unknown} value - Whatever the metadata held.
51
- * @returns {boolean | undefined} - The flag, or undefined if it said nothing.
52
- */
53
- export function metadataFlag(value) {
54
- if (value === undefined || value === null || value === '') return undefined;
55
- if (typeof value === 'boolean') return value;
56
- if (typeof value === 'number') return value !== 0;
57
- const text = String(value).trim().toLowerCase();
58
- if (['true', '1', 'yes'].includes(text)) return true;
59
- if (['false', '0', 'no'].includes(text)) return false;
60
- return undefined;
61
- }
62
-
63
- /**
64
- * The tile encoding an archive declares, if it is one MapLibre understands.
65
- *
66
- * See docs/tilejson.md β€” `encoding`.
67
- * @param {unknown} value - Whatever the metadata held.
68
- * @returns {string | undefined} - A known encoding, or undefined.
69
- */
70
- export function metadataEncoding(value) {
71
- if (typeof value !== 'string') return undefined;
72
- const text = value.trim().toLowerCase();
73
- // An unrecognised value is dropped: it would cost a client its own default.
74
- return ['terrarium', 'mapbox', 'custom', 'mlt'].includes(text)
75
- ? text
76
- : undefined;
77
- }
78
-
79
- /**
80
- * The four factors a `custom` encoding is unreadable without. All or nothing.
81
- * @param {object} metadata - The archive's metadata.
82
- * @returns {object | undefined} - The four factors, or undefined.
83
- */
84
- export function customEncodingFactors(metadata) {
85
- const named = ['redFactor', 'greenFactor', 'blueFactor', 'baseShift'];
86
- const out = {};
87
- for (const name of named) {
88
- const value = Number(metadata[name]);
89
- if (!Number.isFinite(value)) return undefined;
90
- out[name] = value;
91
- }
92
- return out;
93
- }
94
-
95
13
  /**
96
14
  * Reads an archive's header and metadata.
97
15
  *
@@ -99,72 +17,7 @@ export function customEncodingFactors(metadata) {
99
17
  * even for a multi-terabyte archive β€” and it works against an HTTP URL without
100
18
  * downloading it.
101
19
  * @param {string} location - Local path or http(s) URL.
102
- * @returns {Promise<PMTilesSummary>} - The summary.
103
- */
104
- /**
105
- * What this prober reads. Raise it whenever a field is added to the summary,
106
- * and archives probed by an older build are re-read once. See
107
- * docs/internals.md β€” "Re-reading a summary an older prober wrote".
108
- */
109
- export const SUMMARY_VERSION = 3;
110
-
111
- /**
112
- * What the catalog keeps about an archive, from its header and metadata.
113
- * @param {object} header - A parsed PMTiles v3 header.
114
- * @param {object} [metadata] - The archive's own metadata document.
115
- * @returns {object} - The summary, at `SUMMARY_VERSION`.
116
- */
117
- export function summarize(header, metadata = {}) {
118
- const type = TILE_TYPES[header.tileType] ?? TILE_TYPES[0];
119
-
120
- // An archive with no bounds set reports all zeroes; treat that as global
121
- // rather than as a point at null island.
122
- const hasBounds = !(
123
- header.minLon === 0 &&
124
- header.minLat === 0 &&
125
- header.maxLon === 0 &&
126
- header.maxLat === 0
127
- );
128
-
129
- return {
130
- summaryVersion: SUMMARY_VERSION,
131
- specVersion: header.specVersion,
132
- format: type.format,
133
- contentType: type.contentType,
134
- minZoom: header.minZoom,
135
- maxZoom: header.maxZoom,
136
- bounds: hasBounds
137
- ? [header.minLon, header.minLat, header.maxLon, header.maxLat]
138
- : [-180, -85.051129, 180, 85.051129],
139
- center: [
140
- header.centerLon,
141
- header.centerLat,
142
- header.centerZoom || Math.round(header.maxZoom / 2),
143
- ],
144
- tileCount: header.numAddressedTiles,
145
- clustered: header.clustered,
146
- name: metadata.name,
147
- description: metadata.description,
148
- attribution: metadata.attribution,
149
- vectorLayers: metadata.vector_layers,
150
- // What the archive says about its own missing tiles. tileserver-gl reads
151
- // the same key, so an archive built to be served there carries the answer
152
- // with it and does not have to be configured again here.
153
- sparse: metadataFlag(metadata.sparse),
154
- // The header settles MLT; only the metadata can settle elevation packing.
155
- encoding:
156
- type.format === 'mlt' ? 'mlt' : metadataEncoding(metadata.encoding),
157
- encodingFactors:
158
- metadataEncoding(metadata.encoding) === 'custom'
159
- ? customEncodingFactors(metadata)
160
- : undefined,
161
- };
162
- }
163
-
164
- /**
165
- * Reads an archive's header and metadata.
166
- * @param {string} location - Local path or http(s) URL.
167
- * @returns {Promise<PMTilesSummary>} - The summary.
20
+ * @returns {Promise<ArchiveSummary>} - The summary.
168
21
  */
169
22
  export async function probePMTiles(location) {
170
23
  const isHttp = /^https?:\/\//i.test(location);
package/src/tiles.js CHANGED
@@ -7,7 +7,7 @@ import { TorrentSource } from 'pmtiles-torrent';
7
7
  import { NodeFileSource } from './file-source.js';
8
8
  import { onDiskPath } from './incomplete.js';
9
9
  import { MbtilesArchive, isMbtiles } from './mbtiles.js';
10
- import { summarize } from './pmtiles-probe.js';
10
+ import { summarize } from './archive-summary.js';
11
11
  import { LibtorrentReadEngine } from './read-engine.js';
12
12
 
13
13
  /**
@@ -1823,6 +1823,43 @@
1823
1823
  }
1824
1824
  };
1825
1825
 
1826
+ /**
1827
+ * Whether this archive has a tile endpoint at all.
1828
+ *
1829
+ * The same rule the tile route applies, rather than a second opinion
1830
+ * about it: PMTiles always, MBTiles once the file is whole. MBTiles is
1831
+ * SQLite, so it cannot be read a byte range at a time out of the swarm β€”
1832
+ * but a complete one on local disk is an ordinary database holding the
1833
+ * same tiles, and refusing to serve it is refusing something that works.
1834
+ * See docs/internals.md β€” "Serving an MBTiles archive".
1835
+ * @param {object} entry - A catalog entry.
1836
+ * @returns {boolean} - Whether to offer the tile endpoints.
1837
+ */
1838
+ const isServable = (entry) => {
1839
+ const kind = entry.kind ?? 'pmtiles';
1840
+ if (kind === 'pmtiles') return true;
1841
+ return kind === 'mbtiles' && entry.complete === true;
1842
+ };
1843
+
1844
+ /**
1845
+ * What to say under the size about an archive that is not PMTiles.
1846
+ *
1847
+ * An MBTiles that is here in full is servable, and saying "not servable"
1848
+ * beside a finished file was simply wrong. One that is still arriving is
1849
+ * not servable *yet*, which is a different thing to tell somebody: it
1850
+ * becomes one by waiting, where an .osm.pbf never will.
1851
+ * @param {object} entry - A catalog entry.
1852
+ * @returns {string} - The note, or empty for a PMTiles archive.
1853
+ */
1854
+ const kindNote = (entry) => {
1855
+ const kind = entry.kind ?? 'pmtiles';
1856
+ if (kind === 'pmtiles') return '';
1857
+ if (kind !== 'mbtiles') return `${escapeHtml(kind)} Β· not servable`;
1858
+ return entry.complete === true
1859
+ ? escapeHtml(kind)
1860
+ : `${escapeHtml(kind)} Β· servable once complete`;
1861
+ };
1862
+
1826
1863
  /**
1827
1864
  * Closes the details, panel and row together.
1828
1865
  *
@@ -1891,8 +1928,8 @@
1891
1928
  .join(' Β· ')}</div>
1892
1929
  </td>
1893
1930
  <td>${bytes(entry.size)}${
1894
- (entry.kind ?? 'pmtiles') !== 'pmtiles'
1895
- ? `<div class="sub">${escapeHtml(entry.kind)} Β· not servable</div>`
1931
+ kindNote(entry)
1932
+ ? `<div class="sub">${kindNote(entry)}</div>`
1896
1933
  : ''
1897
1934
  }</td>
1898
1935
  <td>${added(entry.createdAt)}</td>
@@ -2518,10 +2555,15 @@
2518
2555
 
2519
2556
  const base = location.origin;
2520
2557
  const tileJson = `${base}/archives/${entry.infoHash}/tiles.json`;
2521
- // Only PMTiles can be a tile endpoint. Offering the URL for anything
2522
- // else hands out a link that will never work and reads as a fault in
2523
- // the server rather than a fact about the archive.
2524
- const servable = (entry.kind ?? 'pmtiles') === 'pmtiles';
2558
+ // Offering the URL for something that cannot serve tiles hands out a
2559
+ // link that will never work and reads as a fault in the server rather
2560
+ // than a fact about the archive.
2561
+ const servable = isServable(entry);
2562
+ // Warming pulls the pieces a region's tiles live in, so it only means
2563
+ // anything for an archive read out of the swarm. An MBTiles is read
2564
+ // whole or not at all β€” once it is here there is nothing left to
2565
+ // fetch, and before that there is no tile to warm.
2566
+ const warmable = (entry.kind ?? 'pmtiles') === 'pmtiles';
2525
2567
  const torrentUrl = `${base}/archives/${entry.infoHash}/archive.torrent`;
2526
2568
  const summary = entry.pmtiles ?? {};
2527
2569
  const mode = entry.mode ?? 'mirror';
@@ -2606,11 +2648,18 @@
2606
2648
  ${
2607
2649
  servable
2608
2650
  ? `<div class="sub" style="margin-bottom:1rem"><code>${escapeHtml(tileJson)}</code></div>`
2609
- : `<div class="sub" style="margin-bottom:1rem">
2610
- This is ${escapeHtml(entry.kind ?? 'not a PMTiles')} archive, so it has no tile
2611
- endpoint β€” only PMTiles can be read a byte range at a time, which is what
2612
- serving tiles from a swarm needs. It is still distributed and seeded normally.
2613
- </div>`
2651
+ : (entry.kind ?? 'pmtiles') === 'mbtiles'
2652
+ ? `<div class="sub" style="margin-bottom:1rem">
2653
+ This MBTiles archive is still arriving, so it has no tile endpoint yet. It is
2654
+ SQLite, which cannot be read a byte range at a time out of the swarm the way
2655
+ PMTiles can β€” so it becomes servable when the download finishes rather than as
2656
+ pieces land. It is distributed and seeded normally in the meantime.
2657
+ </div>`
2658
+ : `<div class="sub" style="margin-bottom:1rem">
2659
+ This is ${escapeHtml(entry.kind ?? 'not a PMTiles')} archive, so it has no tile
2660
+ endpoint β€” only PMTiles and MBTiles can be served as tiles. It is still
2661
+ distributed and seeded normally.
2662
+ </div>`
2614
2663
  }
2615
2664
 
2616
2665
  <h2>Completion hook</h2>
@@ -2665,7 +2714,7 @@
2665
2714
 
2666
2715
  <h2>Actions</h2>
2667
2716
  <div class="actions">
2668
- ${servable ? '<button id="warm">Warm region</button>' : ''}
2717
+ ${warmable ? '<button id="warm">Warm region</button>' : ''}
2669
2718
  <button id="set-location">Set location…</button>
2670
2719
  <button id="clear-cache" ${mode === 'cache' ? '' : 'disabled title="only cache-mode archives have a cache to clear"'}>Clear cache</button>
2671
2720
  <button id="recheck" title="Hash the files on disk again and believe the result. For an archive that reads 0% next to a file that is plainly there, or one that claims to be complete and is not β€” every other figure comes from something written down earlier, and this is the only thing that goes and looks.">Recheck files</button>
@@ -2895,7 +2944,7 @@
2895
2944
  }
2896
2945
  $('copy-hash').onclick = () => copy(entry.infoHash, 'Infohash');
2897
2946
 
2898
- if (servable) {
2947
+ if (warmable) {
2899
2948
  $('warm').onclick = () => openWarmDialog(entry);
2900
2949
  }
2901
2950