pmtiles-swarm 0.6.0 → 0.7.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,30 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.7.0
11
+ ### ✨ Features and improvements
12
+ - **A feed can be told how long to keep what it brings in.** `keep` and `keepDays` now work on a
13
+ subscription, applied by the same code a watched folder and a scheduled source retire under and
14
+ with the same guards: only after something new has landed, and never the newest copy. A feed
15
+ publishing weekly leaves a copy behind every week and the publisher goes on listing all of them,
16
+ so `keepDays: 10` against a weekly feed keeps a fortnight and drops the rest.
17
+
18
+ This is not what `prune` does, and the difference is why a feed needed its own answer. Pruning is
19
+ about the publisher — it stopped offering this, so let it go — and needs a complete listing for an
20
+ absence to mean anything, which is why it applies to a catalogue and not to a feed.
21
+ planet.openstreetmap.org lists five dumps and says nothing about the hundreds before them. Age is
22
+ age however short the list is.
23
+ - **Feeds and remote nodes are two sections rather than one table with a dropdown.** They are not
24
+ one thing in two costumes: a feed is bounded and says "here is what is new", so it caps items per
25
+ check and can never prune; a catalogue says "here is everything", which is the only thing that
26
+ makes an absence meaningful. Half the columns applied to one and half to the other. A row is now
27
+ RSS or API because of the table it sits in, which also means a saved row states its protocol
28
+ instead of leaving it to be guessed from the URL later. Rows written before this are sorted into
29
+ a section by the same rule the subscription manager itself uses.
30
+ - **A feed's save path is editable.** `savePath` was accepted in the configuration and offered
31
+ nowhere, so it could only be set by hand — and before the row editor learned to keep fields it
32
+ does not show, pressing Save would have deleted it.
33
+
10
34
  ## 0.6.0
11
35
  ### ✨ Features and improvements
12
36
  - **Check now, on scheduled sources and on feeds.** A schedule describes ordinary operation, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.6.0",
3
+ "version": "0.7.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/config.js CHANGED
@@ -555,6 +555,21 @@ const DEFAULTS = {
555
555
  * and pruning, which needs to be able to notice an absence.
556
556
  */
557
557
  subscriptions: [],
558
+ /*
559
+ * A subscription also takes `keep` and `keepDays`, which are the same rules
560
+ * a watched folder and a scheduled source retire under and are applied by
561
+ * the same code.
562
+ *
563
+ * They answer a different question from `prune`. Pruning is about the
564
+ * publisher — it stopped offering this, so let it go — and needs a complete
565
+ * listing to mean anything, which is why it applies to a catalog and not to
566
+ * an RSS feed. These are about the disk: a feed publishing weekly leaves a
567
+ * copy behind every week and goes on listing all of them, and age is age
568
+ * however short the list is.
569
+ *
570
+ * Retirement runs only after something new has landed, and never removes
571
+ * the newest copy.
572
+ */
558
573
  /**
559
574
  * How often to re-check whether the sources archives were built from have
560
575
  * changed, in seconds. Zero disables it. A check is one HEAD request or stat
@@ -1,4 +1,5 @@
1
1
  import { parseFeed } from './feed.js';
2
+ import { retains, retire } from './retention.js';
2
3
 
3
4
  /**
4
5
  * Follows other nodes' feeds and picks up what they publish.
@@ -174,6 +175,12 @@ export class SubscriptionManager {
174
175
  break;
175
176
  }
176
177
  }
178
+
179
+ // The disk side of following a feed. Pruning cannot apply here — absence
180
+ // from a bounded feed proves nothing — but age applies whatever the list
181
+ // is, which is what makes this the answer for a feed that publishes
182
+ // weekly for ever.
183
+ await this.#retire(subscription, added);
177
184
  return added;
178
185
  }
179
186
 
@@ -252,9 +259,46 @@ export class SubscriptionManager {
252
259
  }
253
260
 
254
261
  await this.#prune(subscription, document, archives);
262
+ await this.#retire(subscription, added);
255
263
  return added;
256
264
  }
257
265
 
266
+ /**
267
+ * Removes what this feed's archives have outgrown.
268
+ *
269
+ * Separate from pruning, and answering a different question. Pruning is
270
+ * about the *publisher*: it has stopped offering this, so let it go. This is
271
+ * about the disk: a feed publishing weekly leaves a copy behind every week,
272
+ * and the publisher will go on listing all of them.
273
+ *
274
+ * That distinction is why an RSS feed can have this and cannot have pruning.
275
+ * Absence from a bounded feed is not evidence that anything was withdrawn —
276
+ * planet.openstreetmap.org lists five dumps and says nothing about the
277
+ * hundreds before them — but age is age however short the list is.
278
+ *
279
+ * Only after something new has landed, and never the newest copy, which are
280
+ * the same guards a scheduled source retires under. See `retire`.
281
+ * @param {object} subscription - The subscription.
282
+ * @param {object[]} added - What this pass took, newest first.
283
+ * @returns {Promise<void>} - Resolves once anything due has gone.
284
+ */
285
+ async #retire(subscription, added) {
286
+ if (added.length === 0 || !retains(subscription)) return;
287
+
288
+ await retire({
289
+ library: this.#library,
290
+ // Only what this feed brought in. An archive built here, added by hand,
291
+ // or taken from another peer is not this subscription's to remove.
292
+ family: this.#library.catalog
293
+ .list()
294
+ .filter((entry) => entry.source?.subscription === subscription.url),
295
+ entry: added[0],
296
+ keep: subscription.keep,
297
+ keepDays: subscription.keepDays,
298
+ label: `[sync] ${subscription.url}`,
299
+ });
300
+ }
301
+
258
302
  /**
259
303
  * Drops archives this subscription no longer lists.
260
304
  *
@@ -2419,6 +2419,14 @@
2419
2419
  checkNow,
2420
2420
  } = spec;
2421
2421
  rowEditorColumns[key] = columns;
2422
+ // Where this editor's rows belong, and what they are on top of being
2423
+ // rows. Two editors can be two views of one array — feeds and peers
2424
+ // are both subscriptions — so the panel's own key is a DOM name, and
2425
+ // this is where its records are written.
2426
+ rowEditorTargets[key] = {
2427
+ configKey: spec.configKey ?? key,
2428
+ stamp: spec.stamp ?? {},
2429
+ };
2422
2430
  // Kept so a save can put back what this editor never showed. An entry
2423
2431
  // holds more than there are columns for it — a watch folder's
2424
2432
  // pieceLength, a subscription's savePath — and a save that rebuilt each
@@ -2433,7 +2441,9 @@
2433
2441
  <td>
2434
2442
  ${
2435
2443
  column.options
2436
- ? `<select data-field="${column.field}" style="width:${column.wide ? '17rem' : '9rem'}">
2444
+ ? `<select data-field="${column.field}"${
2445
+ column.onlyFor ? ` data-only-for="${column.onlyFor}"` : ''
2446
+ } style="width:${column.wide ? '17rem' : '9rem'}">
2437
2447
  ${column.options
2438
2448
  .map(
2439
2449
  ([option, label]) =>
@@ -2445,6 +2455,7 @@
2445
2455
  </select>`
2446
2456
  : `<input
2447
2457
  data-field="${column.field}"
2458
+ ${column.onlyFor ? `data-only-for="${column.onlyFor}"` : ''}
2448
2459
  ${column.secret ? 'type="password" autocomplete="off"' : ''}
2449
2460
  placeholder="${escapeHtml(column.placeholder ?? '')}"
2450
2461
  style="width:${column.wide ? '17rem' : '9rem'}"
@@ -2508,6 +2519,40 @@
2508
2519
  const tbody = panel.querySelector('tbody');
2509
2520
  const note = panel.querySelector('[data-role="note"]');
2510
2521
 
2522
+ /**
2523
+ * Greys out the fields the chosen protocol does not read.
2524
+ *
2525
+ * The two halves of a subscription are not the same thing wearing
2526
+ * different clothes. `newest` caps how many items a bounded feed hands
2527
+ * over and means nothing to a catalog, which lists everything; `prune`
2528
+ * needs a complete listing to make absence meaningful and so cannot
2529
+ * apply to a feed. Offering both to both invites a setting that is
2530
+ * quietly ignored, which is worse than not offering it.
2531
+ *
2532
+ * Left alone on "auto", since the protocol is then decided by the URL
2533
+ * and this cannot know which it will be.
2534
+ * @param {HTMLElement} row - The row to update.
2535
+ * @returns {void}
2536
+ */
2537
+ const applyProtocol = (row) => {
2538
+ const chosen = row.querySelector('[data-field="protocol"]')?.value;
2539
+ for (const field of row.querySelectorAll('[data-only-for]')) {
2540
+ const belongs = field.dataset.onlyFor;
2541
+ const off = Boolean(chosen) && chosen !== belongs;
2542
+ field.disabled = off;
2543
+ field.title = off
2544
+ ? `Only used by the ${belongs === 'rss' ? 'RSS' : 'catalog API'} protocol`
2545
+ : '';
2546
+ }
2547
+ };
2548
+
2549
+ for (const row of tbody.querySelectorAll('tr')) applyProtocol(row);
2550
+ panel.addEventListener('change', (event) => {
2551
+ if (event.target.dataset?.field === 'protocol') {
2552
+ applyProtocol(event.target.closest('tr'));
2553
+ }
2554
+ });
2555
+
2511
2556
  panel.onclick = async (event) => {
2512
2557
  const token = event.target.dataset?.token;
2513
2558
  if (token) {
@@ -2662,6 +2707,7 @@
2662
2707
  for (const [key, columns] of Object.entries(specs)) {
2663
2708
  const panel = document.querySelector(`[data-row-editor="${key}"]`);
2664
2709
  if (!panel) continue;
2710
+ const target = rowEditorTargets[key] ?? { configKey: key, stamp: {} };
2665
2711
  const originals = rowEditorRows[key] ?? [];
2666
2712
  const records = [...panel.querySelectorAll('tbody tr')]
2667
2713
  .map((row) => {
@@ -2675,8 +2721,17 @@
2675
2721
  origin === undefined ? {} : (originals[Number(origin)] ?? {}),
2676
2722
  );
2677
2723
  })
2678
- .filter((record) => record[columns[0].field]);
2679
- updates[key] = records;
2724
+ .filter((record) => record[columns[0].field])
2725
+ // What the section says about every row in it. A row in the feeds
2726
+ // table is an RSS subscription because of where it is, which is
2727
+ // the point of having two tables rather than a dropdown.
2728
+ .map((record) => ({ ...record, ...target.stamp }));
2729
+
2730
+ // Appended, not assigned: several editors may write one setting.
2731
+ updates[target.configKey] = [
2732
+ ...(updates[target.configKey] ?? []),
2733
+ ...records,
2734
+ ];
2680
2735
  }
2681
2736
  return updates;
2682
2737
  }
@@ -3168,6 +3223,7 @@
3168
3223
  // ── Settings ──────────────────────────────────────────────────────────
3169
3224
  let restartKeys = new Set();
3170
3225
  let rowEditorColumns = {};
3226
+ let rowEditorTargets = {};
3171
3227
  // What each editor was rendered from, so a save can put back the fields
3172
3228
  // it never showed.
3173
3229
  let rowEditorRows = {};
@@ -3229,6 +3285,7 @@
3229
3285
  // are lists of small records, which is exactly what a torrent client
3230
3286
  // gives a grid for, and what a textarea full of braces is worst at.
3231
3287
  rowEditorColumns = {};
3288
+ rowEditorTargets = {};
3232
3289
  rowEditorRows = {};
3233
3290
  await renderTokenEditor(body);
3234
3291
  renderHookEditor(body, config, restartKeys);
@@ -3460,35 +3517,100 @@
3460
3517
  </div>`;
3461
3518
  body.append(feedGlobals);
3462
3519
 
3520
+ // Which table a subscription belongs in — the same question the
3521
+ // subscription manager asks itself: an explicit protocol decides it, and
3522
+ // otherwise the shape of the URL does.
3523
+ const isPeer = (row) =>
3524
+ row.protocol === 'api' ||
3525
+ (!row.protocol && /\/api\/catalog\/?$/.test(row.url ?? ''));
3526
+ const following = config.subscriptions ?? [];
3527
+
3463
3528
  renderRowEditor({
3464
3529
  into: body,
3465
- key: 'subscriptions',
3466
- title: 'Remote nodes',
3530
+ key: 'feeds',
3531
+ configKey: 'subscriptions',
3532
+ // A row here is RSS because of the table it is in, which is the
3533
+ // point of two tables rather than a dropdown nobody reads.
3534
+ stamp: { protocol: 'rss' },
3535
+ title: 'RSS feeds',
3467
3536
  blurb:
3468
- 'Feeds this node follows: another swarm node, or any RSS feed ' +
3469
- 'that carries torrents planet.openstreetmap.org publishes one ' +
3470
- 'for the planet dumps. An RSS feed says "here is what is new" ' +
3471
- 'and is bounded by the publisher, so a node offline long enough ' +
3472
- 'misses things for good; a catalog URL says "here is ' +
3473
- 'everything", which is what makes reconciling possible. A token, ' +
3474
- 'where the peer issued one, may get you more than it publishes ' +
3475
- 'to the world.',
3537
+ 'Any feed that carries torrents — another swarm node publishes ' +
3538
+ 'one, and so does planet.openstreetmap.org for the planet dumps. ' +
3539
+ 'A feed says "here is what is new" and is bounded by whoever ' +
3540
+ 'publishes it, so a node offline long enough misses things for ' +
3541
+ 'good, and an absence from one means nothing at all.',
3476
3542
  columns: [
3477
3543
  {
3478
3544
  field: 'url',
3479
- label: 'Feed or catalog URL',
3480
- placeholder: 'https://peer.example.org/feed.xml',
3545
+ label: 'Feed URL',
3546
+ placeholder: 'https://planet.openstreetmap.org/pbf/planet-pbf-rss.xml',
3481
3547
  wide: true,
3482
3548
  },
3483
3549
  {
3484
- field: 'protocol',
3485
- label: 'Protocol',
3550
+ field: 'mode',
3551
+ label: 'Take as',
3486
3552
  options: [
3487
- ['', 'auto'],
3488
- ['rss', 'RSS'],
3489
- ['api', 'catalog API'],
3553
+ ['cache', 'cache — on demand'],
3554
+ ['mirror', 'mirror — whole copy'],
3490
3555
  ],
3491
3556
  },
3557
+ { field: 'newest', label: 'Items per check', placeholder: '1', number: true },
3558
+ {
3559
+ field: 'categories',
3560
+ label: 'Categories',
3561
+ placeholder: 'from-peer, planet',
3562
+ list: true,
3563
+ },
3564
+ { field: 'filter', label: 'Name filter', placeholder: 'terrain' },
3565
+ { field: 'savePath', label: 'Save to', placeholder: 'the default' },
3566
+ { field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
3567
+ { field: 'keep', label: 'Copies to keep', placeholder: 'all', number: true },
3568
+ { field: 'keepDays', label: 'Keep for (days)', placeholder: 'for ever', number: true },
3569
+ {
3570
+ field: 'enabled',
3571
+ label: 'On',
3572
+ boolean: true,
3573
+ options: [
3574
+ ['', 'yes'],
3575
+ ['false', 'no'],
3576
+ ],
3577
+ },
3578
+ ],
3579
+ rows: following.filter((row) => !isPeer(row)),
3580
+ checkNow: '/api/subscriptions/refresh',
3581
+ peerPreview: true,
3582
+ footnote:
3583
+ 'Items per check is 1 by default and counts from the newest, ' +
3584
+ 'because a feed listing five planet dumps is four hundred ' +
3585
+ 'gigabytes if you take the lot; 0 takes everything it lists. ' +
3586
+ 'Copies to keep and Keep for are about your disk rather than the ' +
3587
+ 'publisher: a feed publishing weekly leaves a copy behind every ' +
3588
+ 'week and goes on listing all of them. Both run only after ' +
3589
+ 'something new has landed, and neither removes the newest copy. ' +
3590
+ 'There is no dropping here — absence from a bounded feed is not ' +
3591
+ 'evidence that anything was withdrawn, which is what a catalogue ' +
3592
+ 'is for.',
3593
+ });
3594
+
3595
+ renderRowEditor({
3596
+ into: body,
3597
+ key: 'peers',
3598
+ configKey: 'subscriptions',
3599
+ stamp: { protocol: 'api' },
3600
+ title: 'Remote nodes',
3601
+ blurb:
3602
+ 'Another swarm node, followed through its catalogue rather than ' +
3603
+ 'its feed. A catalogue says "here is everything", which is what ' +
3604
+ 'makes reconciling possible — and dropping, which needs to be ' +
3605
+ 'able to notice an absence. A token, where the peer issued one, ' +
3606
+ 'may get you more than it publishes to the world.',
3607
+ columns: [
3608
+ {
3609
+ field: 'url',
3610
+ label: 'Catalogue URL',
3611
+ placeholder: 'https://peer.example.org/api/catalog',
3612
+ wide: true,
3613
+ },
3492
3614
  {
3493
3615
  field: 'mode',
3494
3616
  label: 'Take as',
@@ -3497,19 +3619,14 @@
3497
3619
  ['mirror', 'mirror — whole copy'],
3498
3620
  ],
3499
3621
  },
3500
- {
3501
- field: 'newest',
3502
- label: 'Items per check',
3503
- placeholder: '1',
3504
- number: true,
3505
- },
3506
3622
  {
3507
3623
  field: 'categories',
3508
3624
  label: 'Categories',
3509
- placeholder: 'from-peer, planet',
3625
+ placeholder: 'from-peer',
3510
3626
  list: true,
3511
3627
  },
3512
3628
  { field: 'filter', label: 'Name filter', placeholder: 'terrain' },
3629
+ { field: 'savePath', label: 'Save to', placeholder: 'the default' },
3513
3630
  { field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
3514
3631
  {
3515
3632
  field: 'prune',
@@ -3521,6 +3638,8 @@
3521
3638
  ['delete', 'forget and delete'],
3522
3639
  ],
3523
3640
  },
3641
+ { field: 'keep', label: 'Copies to keep', placeholder: 'all', number: true },
3642
+ { field: 'keepDays', label: 'Keep for (days)', placeholder: 'for ever', number: true },
3524
3643
  {
3525
3644
  field: 'enabled',
3526
3645
  label: 'On',
@@ -3531,7 +3650,7 @@
3531
3650
  ],
3532
3651
  },
3533
3652
  ],
3534
- rows: config.subscriptions ?? [],
3653
+ rows: following.filter(isPeer),
3535
3654
  checkNow: '/api/subscriptions/refresh',
3536
3655
  peerPreview: true,
3537
3656
  footnote:
@@ -3540,10 +3659,7 @@
3540
3659
  'every archive the peer lists. Dropping stays off unless chosen, ' +
3541
3660
  'only ever considers archives this peer sent, and never acts on a ' +
3542
3661
  'filtered or partial view — watch a new peer on "report only" ' +
3543
- 'before trusting it with anything more. Items per check is 1 by ' +
3544
- 'default and counts from the newest, because a feed listing five ' +
3545
- 'planet dumps is four hundred gigabytes if you take the lot; 0 ' +
3546
- 'takes everything it lists.',
3662
+ 'before trusting it with anything more.',
3547
3663
  });
3548
3664
 
3549
3665
  for (const [key, value] of Object.entries(config)) {