pmtiles-swarm 0.63.0 → 0.64.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
@@ -4,6 +4,165 @@
4
4
  ### ✨ Features and improvements
5
5
  - _...Add new stuff here..._
6
6
 
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.64.0
11
+ ### ✨ Features and improvements
12
+ - **Export a stack to an archive, from the console.** **Export to archive** sits beside Edit and
13
+ Delete on a stack. It runs the recipe over its sources, writes a real `.pmtiles`, and hands the
14
+ file to the library — which hashes it, makes a torrent and puts it in the catalog, so what comes
15
+ out has an infohash and is seeded like anything else here. That is what makes the live endpoint
16
+ the preview of something rather than an endpoint on its own.
17
+
18
+ **Two halves, watched in two places.** Merging is about a stack, so tiles written, tiles skipped
19
+ and the zoom it is working through are reported on the stack. What follows is an archive being
20
+ added, which this node already reports on the archives view through the library's in-progress
21
+ list — so the second half is handed over rather than drawn twice, and the line on the stack says
22
+ where to look.
23
+
24
+ Stopping keeps the work. The checkpoint is the hours already spent, so **Stop export** leaves it
25
+ where the next run picks it up rather than throwing it away. One bake per stack at a time: two
26
+ runs of one recipe write the same checkpoint files over each other, and the second would resume
27
+ the first's work believing it were its own.
28
+
29
+ A bake is identified by the recipe's revision **and what each source resolved to**. The recipe
30
+ alone cannot see the difference: a stack naming a category resolves to whichever build is
31
+ current, so the same recipe over a rebuilt source is a different bake, and a checkpoint that
32
+ could not tell would resume across the change and produce an archive half of one map and half of
33
+ another.
34
+
35
+ The file is dated — `Terrain-20260822.pmtiles` — and the archive's **name** is not. A rebuild
36
+ here keeps its name and mints a new infohash, which is what lets `/latest/<category>/` follow it;
37
+ dating the name would make every build a different map. The date goes in `description`. `name` is
38
+ always written, because these get converted to mbtiles by other tools and a nameless metadata
39
+ block is not valid there.
40
+
41
+ The codec is required for a recipe that asks for pixel work, and refused when the button is
42
+ pressed rather than an hour in. **A cache-mode source is allowed**, which reverses what the
43
+ design first said: reading one pulls pieces through the swarm, and the tile store already holds
44
+ those to a byte budget and drops what it stops using. Slow is the operator's call to make. The
45
+ sources are scanned through the store for the same reason, so a cache-mode archive's directories
46
+ come out of the swarm the way its tiles do.
47
+
48
+ - **The per-tile merge is one function, shared by the route and the bake.** `src/stack-tile.js`.
49
+ The tile route was 380 lines of Express with the whole merge inline — reading each source,
50
+ climbing to a parent, stitching children where tile sizes differ, both pixel spaces, the cache —
51
+ and none of it callable from anywhere else. A bake had to produce exactly what a request would,
52
+ and two implementations of that disagree eventually.
53
+
54
+ What is left in the route is what is genuinely about HTTP: parsing, status codes, the
55
+ abort-on-close wiring, the stats hook. Behaviour is unchanged and the existing stack suites say
56
+ so.
57
+
58
+ - **The bake driver, with checkpointing and cancellation.** `src/bake.js` runs a stack over its
59
+ sources and writes the result as a real archive: union the sources' coverage, merge in tile-id
60
+ order, write, checkpoint, stop when told. `src/pmtiles-scan.js` is the other new piece — it
61
+ reads back *which* tiles an archive holds, which the `pmtiles` package does not offer because
62
+ a tile server never needs to ask.
63
+
64
+ It iterates coverage rather than a zoom range, and that is what decides whether the job is
65
+ possible: a planet at z16 is 5.7 billion coordinates, and asking each one whether a source
66
+ covers it does not finish. The union of what the sources actually hold skips most of the
67
+ pyramid without a single decode.
68
+
69
+ **Sparseness is a consequence rather than a setting.** A tile no source covered is never
70
+ written, so the archive is sparse by construction — and the baked metadata says so, under the
71
+ key tileserver-gl reads and this project's prober reads. `encoding` travels the same way, with
72
+ its four factors when it is `custom`, because a terrain archive that does not say how to read
73
+ its pixels is an image of nothing in particular.
74
+
75
+ **A cancelled bake keeps its work.** Deleting it would make stopping and failing the same
76
+ thing, and this is a job somebody may have been running since yesterday. The checkpoint is the
77
+ buffered tile data, the entries as `serializeDirectory` writes them, and a small JSON state —
78
+ the entries reuse the archive's own serialization rather than inventing a second format for
79
+ the same array. A checkpoint belongs to one revision of one recipe; a checkpoint for anything
80
+ else is discarded rather than continued, because resuming a changed recipe produces an archive
81
+ that is half one map and half another and nothing downstream could tell.
82
+
83
+ The merge is handed in rather than reached for. That keeps the design's own constraint honest
84
+ — one per-tile answer serves both a request and a bake — and it is also what is left to do:
85
+ the orchestration still lives inline in the stack tile route, and extracting it is the next
86
+ step.
87
+
88
+ - **This node can write a PMTiles file.** `src/pmtiles-write.js`: varints, directories, the
89
+ fixed header and a writer that takes tiles one at a time and finalises an archive. The
90
+ `pmtiles` package everything here reads with is read-only, so this is the other half of it,
91
+ ported from the reference implementation in protomaps/PMTiles.
92
+
93
+ It exists for stage 8 of [tile stacks](docs/tile-stacks.md) — baking a stack into a real
94
+ archive with a real infohash — but it is a module on its own and tested as one. Every test
95
+ reads the result back through the same `pmtiles` library that answers served tiles, and one
96
+ reads it through this project's own prober, so an archive written here is one the node can
97
+ take into its catalog.
98
+
99
+ Three deliberate departures from the reference, all of them written down in
100
+ [docs/tile-stacks.md](docs/tile-stacks.md#writing-a-pmtiles-file):
101
+
102
+ Deduplication hashes with a full-length digest rather than the reference's 64 bits. A
103
+ collision does not raise anything — it points one tile at another tile's bytes, in a file
104
+ that is then hashed, torrented and served to other people. At a billion distinct tiles a
105
+ 64-bit hash collides about 2.7% of the time, and at five billion about half the time.
106
+
107
+ Varints are built by division rather than by shifting. JavaScript's bitwise operators are
108
+ 32-bit, so `value >>= 7` mangles any offset past 4 GiB — which in an archive worth baking
109
+ arrives early. There is a test at 6 GiB.
110
+
111
+ Deduplication can be turned off. It is the one part whose memory grows with the archive, one
112
+ entry per distinct tile, and go-pmtiles makes it a flag for the same reason. Run-length
113
+ encoding still applies without it, and for terrain — long runs of identical ocean and
114
+ identical nodata — that is most of the saving.
115
+
116
+ `clustered` is reported honestly rather than assumed. Tiles written out of tile-id order
117
+ still produce a valid archive, and the header says it is unclustered, because an unclustered
118
+ archive cannot answer a range read in one seek — which is the reason this project serves
119
+ PMTiles rather than MBTiles at all.
120
+
121
+ - **The bake and the sync are designed.** [docs/tile-stacks.md](docs/tile-stacks.md) gained
122
+ three sections: what a bake iterates and why it is the sources' own coverage rather than the
123
+ zoom range (a planet at z16 is 5.7 billion tiles, and asking each one whether a source covers
124
+ it is the difference between a job that finishes and one that does not), what running one
125
+ requires, and what syncing a stack between nodes would have to answer.
126
+
127
+ The sync section is the useful half. A stack is a mutable document whose sources are named by
128
+ category or infohash, so the same recipe means different things on different nodes — sent
129
+ somewhere missing a source it either breaks outright or silently resolves to a different map.
130
+ A feed is the wrong shape for that. Baking sidesteps it: a baked stack is an ordinary archive
131
+ with an infohash, and archives already sync.
132
+
133
+ The stage list also said the console reads stacks but does not edit them, which stopped being
134
+ true when the editor shipped.
135
+
136
+ - **A terrain archive says so before you open it.** The console detail, the stacks list and
137
+ the public catalogue now offer two preview buttons where the encoding is one the preview can
138
+ draw as a DEM: the raster keeps its name, and **Terrain** sits beside it. Before this the
139
+ terrain view existed but nothing pointed at it — you had to open the preview and notice a
140
+ link in the header — and nothing on a listing said which archives were terrain at all.
141
+
142
+ The raster stays first and keeps the plain name deliberately. It is the view that shows a
143
+ hole, because a missing DEM tile hillshades as flat ground rather than as missing.
144
+
145
+ `/api/stacks` and the categories payload now carry `encoding` for the same reason, so a page
146
+ can decide without fetching a TileJSON per row.
147
+
148
+ ### 🐞 Bug fixes
149
+ - **A catalogue helper declared in the wrong scope.** The public page renders archives and
150
+ categories from two separate functions, and the terrain check went inside the first — a
151
+ `ReferenceError` the moment a terrain category was drawn. `node --check` accepts it and so
152
+ does a scope check, because the name exists; it is only wrong from the other function.
153
+
154
+ The scope checks that exist for exactly this were only ever run against the console. They now
155
+ run against the catalogue and the preview too, and the helper both renderers share is
156
+ asserted to be declared where both can reach it.
157
+
158
+ - **Three copies of one rule, none of them checked against each other.** The preview decides
159
+ what to draw as terrain, and the console and the catalogue decide whether to offer the
160
+ button. A button offered on something that does not render as terrain is worse than no
161
+ button, so the three are now lifted out of their pages and asserted to agree across eight
162
+ cases — `mlt`, an unknown encoding, and `custom` with none, half and all four of its factors
163
+ among them.
164
+
165
+
7
166
  ### 🐞 Bug fixes
8
167
  - _...Add new stuff here..._
9
168
 
package/NOTICE.md CHANGED
@@ -57,6 +57,29 @@ The prebuilt `@img/sharp-*` packages it resolves carry libvips (LGPL-3.0-or-late
57
57
  dependencies, each under their own terms; sharp links to libvips dynamically and ships it
58
58
  unmodified.
59
59
 
60
+ ## rio-rgbify — MIT
61
+
62
+ > Copyright (c) 2016 Mapbox
63
+ > https://github.com/mapbox/rio-rgbify
64
+
65
+ The original terrain-RGB encoder: it is what established the `base` and `interval` packing that
66
+ `src/elevation.js` decodes and encodes, and the defaults there — base `-10000`, interval `0.1` —
67
+ are Mapbox's Terrain-RGB format rather than a choice made here.
68
+
69
+ [rio-rgbify-merge](https://github.com/TechIdiots-LLC/rio-rgbify-merge) is a fork of it by
70
+ TechIdiots LLC, under the same MIT license and carrying the same copyright line. Tile stacks are
71
+ the on-the-fly counterpart to that fork's offline merge, and the pixel maths is deliberately kept
72
+ the same so a stack previewed live and a stack baked to a file do not disagree — see
73
+ [docs/tile-stacks.md](docs/tile-stacks.md).
74
+
75
+ Which half is whose is worth stating, because the two are credited differently. The encoder traces
76
+ to Mapbox in 2016. The **merge** behaviour this project follows — layer priority, masking by height
77
+ and by colour, sparse tiles — was added in the fork in 2026 and is TechIdiots LLC's own work, not
78
+ Mapbox's.
79
+
80
+ No code is copied either way. `src/elevation.js` and `src/rgba.js` are JavaScript written against
81
+ the same rules, and `docs/tile-stacks.md` records where those rules differ from the fork's and why.
82
+
60
83
  ## qBittorrent — GPL-2.0-or-later
61
84
 
62
85
  > https://github.com/qbittorrent/qBittorrent
package/README.md CHANGED
@@ -804,11 +804,13 @@ which the endpoint answers 501.
804
804
  | `GET` | `/api/stacks` | Every stack, with what each source resolved to and why one cannot be served yet |
805
805
  | `GET` | `/api/torrents/:infoHash/stacks` | Which stacks would break if this archive were removed, and how |
806
806
  | `GET`, `PUT`, `DELETE` | `/api/stacks/:id/raw`, `/api/stacks/:id` | Read a recipe as written, save one, or remove it. `/raw` is the recipe; `/api/stacks` is what it resolved to |
807
- | `GET` | `/stacks/:id/preview` | A map of a stack, for looking at it — **public** |
808
- | `GET` | `/stacks/:id/tiles.json` | TileJSON for a stack — **public**. `maxzoom` is the maximum over its sources, not the minimum |
809
- | `GET` | `/stacks/:id/:size/:z/:x/:y.:ext` | The same tile at 256 or 512 px. A tile's coordinates are an extent rather than a pixel count, so this is the same ground on a finer or coarser grid |
810
- | `GET` | `/stacks/:id/:z/:x/:y.:ext` | One tile of a stack — **public**. Answered by the topmost source holding it; `X-Stack-Sources` says which were asked and what each said |
811
- | `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS — **public** |
807
+
808
+ | `POST`, `DELETE` | `/api/stacks/:id/bake` | Run the stack over its sources and write the result as an archive, or stop one that is running. Answers as soon as the job starts; watch it on `/api/stacks` |
809
+ | `GET` | `/stacks/:id/preview` | A map of a stack, for looking at it — **public** |
810
+ | `GET` | `/stacks/:id/tiles.json` | TileJSON for a stack — **public**. `maxzoom` is the maximum over its sources, not the minimum |
811
+ | `GET` | `/stacks/:id/:size/:z/:x/:y.:ext` | The same tile at 256 or 512 px. A tile's coordinates are an extent rather than a pixel count, so this is the same ground on a finer or coarser grid |
812
+ | `GET` | `/stacks/:id/:z/:x/:y.:ext` | One tile of a stack — **public**. Answered by the topmost source holding it; `X-Stack-Sources` says which were asked and what each said |
813
+ | `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS — **public** |
812
814
 
813
815
  Everything under `/api/` is guarded once a credential is configured; tiles, TileJSON and the feeds
814
816
  never are. A `peer` token may read but not change, and may be narrowed to some categories. See
@@ -1,11 +1,11 @@
1
1
  # Tile stacks
2
2
 
3
- **Status: stages 1 to 6 are implemented, and 7 in part.** Elevation stacks work: a recipe is
3
+ **Status: all eight stages are implemented.** Elevation stacks work: a recipe is
4
4
  defined, resolved and served, and a source is masked, shifted, resampled from a
5
5
  parent and painted in order. Merged tiles are cached on disk against a
6
6
  byte budget, and image stacks composite with opacity and blend modes. The
7
- console lists stacks and diagnoses them; editing them there is still design. A node with no codec installed
8
- still serves the passthrough case and answers 501 for the rest.
7
+ console lists stacks, diagnoses them and edits them. A node with no codec
8
+ installed still serves the passthrough case and answers 501 for the rest.
9
9
 
10
10
  A stack is a recipe for combining several archives into one tile endpoint,
11
11
  evaluated per request rather than baked into a file. Where
@@ -15,7 +15,9 @@ regional lidar patch over a global DEM.
15
15
 
16
16
  This is the on-the-fly counterpart to the offline merge in
17
17
  [rio-rgbify-merge](https://github.com/TechIdiots-LLC/rio-rgbify-merge)
18
- (`pip install rio-rgbify-merge`). The pixel maths is the same and is
18
+ (`pip install rio-rgbify-merge`), a fork of
19
+ [mapbox/rio-rgbify](https://github.com/mapbox/rio-rgbify) which added the merge
20
+ the encoder never had. The pixel maths is the same and is
19
21
  deliberately kept the same, so a stack can be previewed live and then baked into
20
22
  a real archive without the two disagreeing. What the swarm adds is that a source is named by
21
23
  _category_ rather than by path, so a stack keeps working across a rebuild of any
@@ -39,6 +41,14 @@ of its parts.
39
41
  - [TileJSON for a stack](#tilejson-for-a-stack)
40
42
  - [When a source will not answer](#when-a-source-will-not-answer)
41
43
  - [Baking a stack into an archive](#baking-a-stack-into-an-archive)
44
+ - [Writing a PMTiles file](#writing-a-pmtiles-file)
45
+ - [What to iterate](#what-to-iterate)
46
+ - [Running it](#running-it)
47
+ - [What a baked archive says about itself](#what-a-baked-archive-says-about-itself)
48
+ - [What identifies a bake](#what-identifies-a-bake)
49
+ - [Starting one, and watching it](#starting-one-and-watching-it)
50
+ - [What exists now](#what-exists-now)
51
+ - [Syncing a stack to another node](#syncing-a-stack-to-another-node)
42
52
  - [What the offline merge got wrong](#what-the-offline-merge-got-wrong)
43
53
  - [The stack editor](#the-stack-editor)
44
54
  - [Staging](#staging)
@@ -523,9 +533,227 @@ and the merge code is shared. It also inverts the cost argument completely: the
523
533
  swarm-read latency that makes on-the-fly stacking painful is irrelevant to a
524
534
  batch job that runs overnight.
525
535
 
526
- Not in scope for the first version, but the merge implementation should be a
527
- pure function of (tile coordinate, source tiles) → tile bytes, so the bake is a
528
- different driver over the same core rather than a second implementation.
536
+ That constraint was honoured: `mergeElevation` and the RGBA equivalent are pure
537
+ functions of their contributions, so the bake is a different driver over the
538
+ same core rather than a second implementation of it.
539
+
540
+ Most of what a bake needs is already here. `stackCoverage` says what zooms and
541
+ bounds a stack reaches. The merge path answers a tile. `#running` in the library
542
+ already registers a long job with an `AbortController`, a name, a start time and
543
+ a received/total pair, and the console already draws that list, so a bake is
544
+ another entry in it rather than new machinery. `settleFromStaging` exists for
545
+ precisely this shape: work into a staging directory, hash, then move into place
546
+ once the infohash exists, because an infohash cannot be known before the bytes
547
+ do. The last step is `createTorrentFromFile`, which is what every other archive
548
+ built here goes through.
549
+
550
+ One piece is genuinely missing: nothing in this project writes a PMTiles file.
551
+
552
+ ### Writing a PMTiles file
553
+
554
+ The `pmtiles` npm package is read-only. It exports `bytesToHeader`, `findTile`,
555
+ `readVarint` and `zxyToTileId`, and nothing that serialises. So
556
+ `src/pmtiles-write.js` is new, but it is a port of a small, well-understood
557
+ reference rather than a design problem. The Python implementation in
558
+ protomaps/PMTiles is 124 lines for the writer, plus about 70 for
559
+ `serialize_directory` and `serialize_header`; the Hilbert maths does not need
560
+ porting at all, because `zxyToTileId` already ships.
561
+
562
+ The reader shipping in the same package is what makes this cheap to trust: a
563
+ round trip is write it, read it back with `bytesToHeader` and `findTile`, and
564
+ probe it with `pmtiles-probe.js` - the same prober every other archive here goes
565
+ through.
566
+
567
+ Three things the Python reference does that a port must not copy.
568
+
569
+ **It deduplicates on a 64-bit hash.** go-pmtiles uses FNV-128a for the same job,
570
+ and at the scale a bake works at that is not a stylistic difference. A collision
571
+ does not raise anything: it points one tile at another tile's bytes, in a file
572
+ that is then hashed, torrented and served to other people.
573
+
574
+ | distinct tiles | 64-bit | 128-bit |
575
+ | -------------- | -------- | ------- |
576
+ | 10^7 | 3e-06 | ~0 |
577
+ | 10^8 | 3e-04 | ~0 |
578
+ | 10^9 | **2.7%** | ~0 |
579
+ | 5 x 10^9 | **49%** | ~0 |
580
+
581
+ Use a 128-bit digest, and compare the bytes on a hit rather than trusting the
582
+ digest alone. The cost is a comparison against a tile already in hand; the
583
+ failure it prevents is silent and permanent.
584
+
585
+ **It buffers tile data in a temporary file and copies it in at the end**,
586
+ because `tile_data_offset` is not known until the directories have been sized.
587
+ Peak disk is then twice the tile bytes, which for a planet bake is a terabyte of
588
+ transient space. The format does not require it: `tile_data_offset` may be
589
+ anything, so tile data can be written straight into the output at a generous
590
+ fixed offset, leaving a small hole rather than making a second copy.
591
+
592
+ **It detects `clustered` rather than requiring it.** Writing tiles in ascending
593
+ tile-id order is what makes an archive answer a range read in one seek, and
594
+ range reads over HTTP are the reason this project serves PMTiles rather than
595
+ MBTiles at all. An unclustered bake is valid and bad at the only thing it exists
596
+ for, so ordering is a requirement of the bake here, not an outcome to be
597
+ reported.
598
+
599
+ The run-length encoding in `write_tile` is worth keeping exactly as it is:
600
+ consecutive tile ids sharing an offset collapse into one entry, and for terrain,
601
+ with its long runs of identical ocean and identical nodata, that is most of the
602
+ saving.
603
+
604
+ ### What to iterate
605
+
606
+ Not the zoom range. A planet at z0-z14 is around 350 million tiles and at z16
607
+ around 5.7 billion, and enumerating that to ask each one whether any source
608
+ covers it is the difference between a job that finishes and one that does not.
609
+
610
+ Iterate the sources' own coverage instead. Every source is a PMTiles archive
611
+ whose directories already say which tiles it has; the union of those, in tile-id
612
+ order, is exactly the set a stack can answer for. For terrain over ocean that
613
+ skips most of the pyramid without a single decode.
614
+
615
+ `sparse` follows from the same fact and needs no separate decision: a tile no
616
+ source covered is not written, and the baked archive is sparse for the same
617
+ reason the live stack answers 404.
618
+
619
+ ### Running it
620
+
621
+ - **The codec is required**, and refused up front. A bake that is not pure
622
+ passthrough decodes and re-encodes every tile, so `sharp` stops being optional
623
+ for it — and a node without one should find out when it presses the button,
624
+ not an hour in.
625
+ - **A cache-mode source is allowed.** An earlier draft here said to refuse one.
626
+ That was wrong: reading a cache-mode archive pulls pieces through the swarm,
627
+ and the tile store already holds those to a byte budget and drops what it
628
+ stops using. A long bake against a cached source is therefore slow, not
629
+ unbounded — and slow is the operator's call to make, not this document's. The
630
+ sources are scanned through the store for the same reason, so a cache-mode
631
+ archive's directories come out of the swarm the way its tiles do.
632
+ - **Checkpointing.** Hours to days means the process will be interrupted, and
633
+ resume state is the part of this project that has already gone subtly wrong
634
+ once - see `tools/resume-doctor.py` and what it exists to diagnose. Design it
635
+ rather than discovering it.
636
+ - **Cancellable**, and stopping keeps the work: realising it is the wrong recipe
637
+ should not mean waiting it out, and it should not mean starting over either.
638
+
639
+ ### What identifies a bake
640
+
641
+ `bakeRevision` is the recipe's revision and what each source resolved to,
642
+ hashed together. The recipe alone is not enough. A stack naming a category
643
+ resolves to whichever build is current, so the same recipe over a rebuilt source
644
+ is a different bake — and a checkpoint that could not tell would resume across
645
+ the change and produce an archive that is half one map and half another.
646
+
647
+ The file is dated: `Terrain-20260822.pmtiles`. The archive's **name** is not.
648
+ A rebuild here keeps its name and mints a new infohash, which is what lets
649
+ `/latest/<category>/` follow it; dating the name would make every build a
650
+ different map. The date goes in `description` instead, so an archive read out of
651
+ context still says what produced it and when.
652
+
653
+ `name` is always written, because these archives get converted to mbtiles by
654
+ other tools and a nameless metadata block is not valid there.
655
+
656
+ ### What a baked archive says about itself
657
+
658
+ Two metadata keys beyond the name, and both are the keys tileserver-gl reads and
659
+ this project's own prober reads, so a baked archive is understood wherever it
660
+ lands.
661
+
662
+ `sparse` is true unless the recipe says otherwise. For a bake this is not a
663
+ claim, it is a description: a tile no source covered is never written, so the
664
+ archive is sparse by construction. The flag is what makes a client overzoom the
665
+ parent rather than draw nothing, and without it a terrain map is full of holes
666
+ that render as sea.
667
+
668
+ `encoding` says how to read the pixels. A terrain-RGB archive without it is an
669
+ image of nothing in particular, and `custom` carries its four factors or is not
670
+ worth writing at all.
671
+
672
+ ### Starting one, and watching it
673
+
674
+ **Export to archive**, on the stack, beside Edit and Delete. `POST
675
+ /api/stacks/<id>/bake` starts it and answers as soon as the job is running: a
676
+ planet bake is hours, and a request that waited for the archive is a request
677
+ nothing could hold open. `DELETE` on the same address stops it.
678
+
679
+ A bake has two halves and they are watched in two places, deliberately. Merging
680
+ is about a stack, so it is reported on the stack — tiles written, tiles skipped,
681
+ the zoom it is working through. What happens afterwards is an archive being
682
+ added, and this node already reports that on the archives view through the
683
+ library's own in-progress list, so the second half is handed over rather than
684
+ drawn twice. The line on the stack says where to look.
685
+
686
+ One bake per stack at a time. Two runs of one recipe write the same checkpoint
687
+ files over each other, and the second would resume the first's work believing it
688
+ were its own.
689
+
690
+ ### What exists now
691
+
692
+ `src/pmtiles-write.js` writes archives, `src/pmtiles-scan.js` reads back what one
693
+ holds, and `src/bake.js` is the driver: union the sources' coverage, merge in
694
+ tile-id order, write, checkpoint, stop when told.
695
+
696
+ The merge itself is handed in as a function, and `mergeTileFor` is what supplies
697
+ it: the same `answerStackTile` the tile route calls, so a baked tile and a served
698
+ one come out of one implementation and cannot drift. The cache is bypassed there
699
+ — it is sized for tiles people ask for twice, and a whole-pyramid run would evict
700
+ all of those in favour of tiles nobody will ask for again.
701
+
702
+ The checkpoint is three files in a working directory: the buffered tile data,
703
+ the entries as `serializeDirectory` writes them, and a small JSON state. Entries
704
+ go through the same serialization the archive itself uses rather than inventing
705
+ a second format for the same array. A checkpoint belongs to one revision of one
706
+ recipe; anything else is discarded rather than continued, because resuming a
707
+ changed recipe produces an archive that is half one map and half another and
708
+ nothing downstream could tell.
709
+
710
+ The deduplication map is deliberately not part of a checkpoint. Rebuilding it
711
+ means re-hashing everything already buffered, and the cost of starting it empty
712
+ is that a tile identical to one from before the interruption is stored twice.
713
+ The archive is correct either way; it is a little larger.
714
+
715
+ ## Syncing a stack to another node
716
+
717
+ The question is whether a stack can travel between nodes the way an archive
718
+ does, and the answer is that it is a different kind of thing.
719
+
720
+ An archive is immutable and content-addressed: an infohash either matches or it
721
+ does not, and two nodes converge because they are fetching the same bytes. A
722
+ stack is a mutable document that gets edited, so syncing it is a question about
723
+ conflicts and clobbering rather than about missing pieces. A feed is the wrong
724
+ shape for it.
725
+
726
+ There is a sharper problem underneath. A recipe names its sources by category or
727
+ by infohash, so **the same recipe means different things on different nodes**.
728
+ Sent to a node missing one source, an infohash-pinned stack is permanently
729
+ broken; a category-named one silently resolves to that node's newest build of
730
+ that category, which may be a different map entirely. Either way the recipe
731
+ travels and the meaning does not.
732
+
733
+ Three shapes, in the order they are worth considering:
734
+
735
+ **Pull into a namespace the receiver owns.** `/api/stacks/<id>/raw` already
736
+ returns the recipe as written. A subscribing node polls a peer's stack list the
737
+ way it already polls for archives, and adopts what it finds under `<peer>:<id>`,
738
+ so an adopted stack can never overwrite a local one and it is obvious on the
739
+ screen which node a recipe came from. This reuses the remote-node machinery
740
+ rather than adding a feed to it. A source that does not resolve locally is
741
+ reported through `problems`, which the console already separates from an invalid
742
+ recipe and from one needing a codec; an adopted stack with a missing source is a
743
+ normal thing to look at, not an error to refuse.
744
+
745
+ **Export a bundle.** The recipe plus the resolved infohashes of its sources, as
746
+ one file. Unambiguous and reproducible, and the right answer when what is wanted
747
+ is _this exact map_. It pins, so it does not follow a rebuild, which is either
748
+ the point or the problem depending on why it was sent.
749
+
750
+ **Nothing automatic.** Copy the recipe and let the operator resolve the sources.
751
+ Honest, and possibly right for as long as stacks are few.
752
+
753
+ Baking sidesteps the question rather than answering it. A baked stack is an
754
+ ordinary archive with an infohash, and archives already sync, so where what is
755
+ wanted is the _output_ on another node rather than the _recipe_, that is the
756
+ mechanism, and it carries no ambiguity about what the sources resolved to.
529
757
 
530
758
  ## What the offline merge got wrong
531
759
 
@@ -743,10 +971,15 @@ still requested and still composited.
743
971
  with a byte budget, LRU eviction and single-flight.
744
972
  6. ~~**RGBA space.**~~ Done. `src/rgba.js`: opacity, the separable blend
745
973
  modes, colour masking and alpha-correct resampling.
746
- 7. **Console.** A Stacks view exists, listing every stack with what each source
747
- resolved to and why one cannot be served. It reads rather than edits; the
748
- editor in [The stack editor](#the-stack-editor) is still design.
749
- 8. **Bake.** Whole-pyramid run to a new `.pmtiles`, published like any other.
974
+ 7. ~~**Console.**~~ Done. A Stacks view listing every stack with what each
975
+ source resolved to and why one cannot be served, and an editor that adds,
976
+ changes and removes them.
977
+ 8. ~~**Bake.**~~ Done. A coverage-driven run to a new `.pmtiles`, hashed and
978
+ registered like any other archive. `src/pmtiles-write.js` writes archives,
979
+ `src/pmtiles-scan.js` says what one holds, `src/stack-tile.js` is the
980
+ per-tile merge shared with the tile route, `src/bake.js` drives the run with
981
+ checkpointing and cancellation, and `src/bake-jobs.js` is the job the console
982
+ starts and watches.
750
983
 
751
984
  Stages 1 and 2 are worth doing on their own even if the rest waits: a
752
985
  category-resolved passthrough endpoint that picks whichever source has the tile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.63.0",
3
+ "version": "0.64.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",