funoteka 0.1.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/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- Two lookups that were scanning a table to answer a question about one row.
|
|
2
|
+
--
|
|
3
|
+
-- Both were found by measuring the playlist-file stage, and neither is about
|
|
4
|
+
-- playlists: they are lookups the API and the importer make, and both grew with
|
|
5
|
+
-- the size of the collection rather than with the size of the question.
|
|
6
|
+
|
|
7
|
+
-- A file by its path.
|
|
8
|
+
--
|
|
9
|
+
-- The unique index on `file (root_id, rel_path)` is declared with the default
|
|
10
|
+
-- BINARY collation, and the importer compares paths with `COLLATE NOCASE` on
|
|
11
|
+
-- Windows — which names the same file there (`01.FLAC` and `01.flac` do not
|
|
12
|
+
-- both exist in one folder). A comparison whose collation differs from the
|
|
13
|
+
-- index's cannot use it, so each entry of a `.m3u` walked every file of the
|
|
14
|
+
-- root: measured on this collection, 80 µs for a path near the start of the
|
|
15
|
+
-- walk and 1083 µs near the end — a list of 5000 entries cost 14 seconds, and a
|
|
16
|
+
-- collection three times larger would have cost three times that with no new
|
|
17
|
+
-- playlist files at all.
|
|
18
|
+
--
|
|
19
|
+
-- On other platforms the importer compares without `COLLATE` and keeps using
|
|
20
|
+
-- the unique index; this one is simply not chosen there, and costs the space it
|
|
21
|
+
-- takes.
|
|
22
|
+
CREATE INDEX file_rel_path_nocase ON file (root_id, rel_path COLLATE NOCASE);
|
|
23
|
+
|
|
24
|
+
-- A playlist by its name.
|
|
25
|
+
--
|
|
26
|
+
-- Asked once per imported `.m3u`, to tell two lists with the same file name
|
|
27
|
+
-- apart (`playlist/import.ts`). Nothing indexed `name`, so each question read
|
|
28
|
+
-- the whole table: quadratic in the number of playlists, cheap today and not
|
|
29
|
+
-- worth leaving as the third such leg.
|
|
30
|
+
CREATE INDEX playlist_name ON playlist (name);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- What the listener marked: stars and ratings.
|
|
2
|
+
--
|
|
3
|
+
-- The second half of the listener's own layer — playlists are the first — and
|
|
4
|
+
-- it shares their rule: a scan does not touch it, because nothing on disk says
|
|
5
|
+
-- what somebody thought of a record.
|
|
6
|
+
--
|
|
7
|
+
-- It differs from a playlist in the one way that decides the schema. A playlist
|
|
8
|
+
-- is a thing of its own; an annotation is *about a row of the model*, so what it
|
|
9
|
+
-- needs is a foreign key to that row and a cascade from it. A star on a track
|
|
10
|
+
-- whose file left the disk is a star on nothing, and nothing is what should be
|
|
11
|
+
-- left of it.
|
|
12
|
+
--
|
|
13
|
+
-- **Three tables rather than one with a `kind` column**, and that is the whole
|
|
14
|
+
-- reason: a polymorphic column cannot carry a foreign key, so `annotation(kind,
|
|
15
|
+
-- item_id)` would leave the cascade to a hand-written sweep somewhere — and a
|
|
16
|
+
-- sweep is a thing that can be forgotten, while `ON DELETE CASCADE` cannot.
|
|
17
|
+
-- The three are written once here and spoken of once in `annotation/store.ts`,
|
|
18
|
+
-- which is where the duplication costs something.
|
|
19
|
+
--
|
|
20
|
+
-- A row exists only while it says something: unstarring the last star and
|
|
21
|
+
-- setting the rating back to zero delete it rather than leaving a row of NULLs,
|
|
22
|
+
-- so `starred_at IS NOT NULL` and `rating` say the same thing in the table as
|
|
23
|
+
-- they do in an answer.
|
|
24
|
+
CREATE TABLE track_annotation (
|
|
25
|
+
track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
|
|
26
|
+
starred_at TEXT,
|
|
27
|
+
rating INTEGER
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE album_annotation (
|
|
31
|
+
album_id INTEGER PRIMARY KEY REFERENCES album(id) ON DELETE CASCADE,
|
|
32
|
+
starred_at TEXT,
|
|
33
|
+
rating INTEGER
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE artist_annotation (
|
|
37
|
+
artist_id INTEGER PRIMARY KEY REFERENCES artist(id) ON DELETE CASCADE,
|
|
38
|
+
starred_at TEXT,
|
|
39
|
+
rating INTEGER
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- The starred listings are the one query that reads a whole table here rather
|
|
43
|
+
-- than a row, and they order by when: an index keeps that from sorting every
|
|
44
|
+
-- annotation of every kind.
|
|
45
|
+
CREATE INDEX track_annotation_starred ON track_annotation (starred_at);
|
|
46
|
+
CREATE INDEX album_annotation_starred ON album_annotation (starred_at);
|
|
47
|
+
CREATE INDEX artist_annotation_starred ON artist_annotation (starred_at);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- The ledger question `current()` asks, indexed.
|
|
2
|
+
--
|
|
3
|
+
-- `db/ledger.ts` asks whether a ledger row was written by the run that last
|
|
4
|
+
-- walked its root, and it asks by comparing that row's run against the newest
|
|
5
|
+
-- one for the same root:
|
|
6
|
+
--
|
|
7
|
+
-- ss.last_seen_run_id = (SELECT MAX(e.last_seen_run_id) FROM scan_state e
|
|
8
|
+
-- WHERE e.root_id = ss.root_id)
|
|
9
|
+
--
|
|
10
|
+
-- Nothing indexed `(root_id, last_seen_run_id)` on `scan_state`, so that `MAX`
|
|
11
|
+
-- read every row of the root — and the fragment is a predicate inside a lookup,
|
|
12
|
+
-- not a query of its own, so it was asked once per lookup rather than once per
|
|
13
|
+
-- stage. That is the whole difference: measured on the live collection, the cue
|
|
14
|
+
-- stage's stored-probe lookup made **3347** calls and cost **1050 ms** of a
|
|
15
|
+
-- 1756 ms stage. With this index the same stage takes **709 ms**, and the lookup
|
|
16
|
+
-- no longer appears among the stage's costs at all (task:2880).
|
|
17
|
+
--
|
|
18
|
+
-- `folder`, `album` and `release` were given this index in 005, for the same
|
|
19
|
+
-- reason — "did this run see it" is a question about the newest run for a root.
|
|
20
|
+
-- `scan_state` was left out, and that cost nothing while the ledger was read
|
|
21
|
+
-- once per root. It stopped being free when a stage began asking it per file:
|
|
22
|
+
-- `tags` asks it to decide which files to read, and the cue stage asks it
|
|
23
|
+
-- whether a stored measurement is still good.
|
|
24
|
+
--
|
|
25
|
+
-- The pair is the shape the question has, in that order: `root_id` is the
|
|
26
|
+
-- equality, `last_seen_run_id` the aggregate — so the `MAX` is answered by the
|
|
27
|
+
-- last entry of the range rather than by reading it.
|
|
28
|
+
CREATE INDEX scan_state_root_run ON scan_state (root_id, last_seen_run_id);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
-- What was played, what is playing, and the queue a listener left behind.
|
|
2
|
+
--
|
|
3
|
+
-- The listener's own layer again, and the third part of it: playlists are what
|
|
4
|
+
-- they arranged, stars are what they thought, and these are what they *did*.
|
|
5
|
+
-- Nothing on disk states any of it, so no scan writes here — and, like the other
|
|
6
|
+
-- two, a scan that takes a row away takes what hangs off it through the cascade.
|
|
7
|
+
--
|
|
8
|
+
-- Five tables rather than two, and the split is the protocol's own. A `play` is
|
|
9
|
+
-- history: it happened, and the row is the only record that it did. `now_playing`
|
|
10
|
+
-- is state: one row per player, replaced as the player moves. The queue is state
|
|
11
|
+
-- too, but the listener's rather than a player's — it exists so a queue can be
|
|
12
|
+
-- resumed on another device, which is why `getPlayQueue` takes no player and why
|
|
13
|
+
-- its entries are a table of their own (`play_queue_entry`) beside it. `player`
|
|
14
|
+
-- is the fifth, and it is not one of the three states but the row that turns a
|
|
15
|
+
-- client's own name into the integer the protocol asks for — see below.
|
|
16
|
+
--
|
|
17
|
+
-- The count is stated because it was wrong here: this said four while creating
|
|
18
|
+
-- five, and a reader counting the tables found a comment that disagreed with the
|
|
19
|
+
-- file beside it.
|
|
20
|
+
|
|
21
|
+
-- Who is playing. The protocol requires a `playerId` (an integer) and an
|
|
22
|
+
-- optional `playerName` on every now-playing entry, and **no endpoint accepts
|
|
23
|
+
-- either**: the server is meant to derive them. A client names itself with the
|
|
24
|
+
-- protocol's own `c` parameter, so that is the name, and this table is what
|
|
25
|
+
-- turns it into the stable small integer the protocol asks for. Deriving the
|
|
26
|
+
-- number from the name by hashing would be the other way; a row is simpler and
|
|
27
|
+
-- it can be read back.
|
|
28
|
+
CREATE TABLE player (
|
|
29
|
+
id INTEGER PRIMARY KEY,
|
|
30
|
+
name TEXT NOT NULL UNIQUE
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- A play that happened.
|
|
34
|
+
--
|
|
35
|
+
-- **`ON DELETE CASCADE` on the track**, which is a decision rather than an
|
|
36
|
+
-- obvious truth: a scrobble is a statement about a *file*, and Last.fm would
|
|
37
|
+
-- keep it after the file left. This meta layer's rule is the other one — every
|
|
38
|
+
-- row below `root` is a reading of what is on disk, and a reading of a file that
|
|
39
|
+
-- is not there is a reading of nothing. A history that outlived its collection
|
|
40
|
+
-- would also have nowhere to point: `track_id` is NOT NULL, so the alternatives
|
|
41
|
+
-- were a nullable column and a client that has to be told what a row without a
|
|
42
|
+
-- track means.
|
|
43
|
+
CREATE TABLE play (
|
|
44
|
+
id INTEGER PRIMARY KEY,
|
|
45
|
+
track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE,
|
|
46
|
+
player_id INTEGER NOT NULL REFERENCES player(id),
|
|
47
|
+
-- When the play happened, as far as this server knows. The protocol lets a
|
|
48
|
+
-- client state it — `time`, optional, and the only thing that dates a play a
|
|
49
|
+
-- client made offline — and when it does not, this is when the server was told.
|
|
50
|
+
played_at TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE INDEX play_track ON play (track_id);
|
|
54
|
+
CREATE INDEX play_played_at ON play (played_at);
|
|
55
|
+
|
|
56
|
+
-- What each player is playing now, and how far in.
|
|
57
|
+
--
|
|
58
|
+
-- One row per player and not per user, because the protocol's question is "what
|
|
59
|
+
-- is being played by all users" and the only thing that tells two of them apart
|
|
60
|
+
-- here is the client. `state`, `position_ms` and `playback_rate` come from
|
|
61
|
+
-- `reportPlayback` (the `playbackReport` extension) and are what `getNowPlaying`
|
|
62
|
+
-- reports back when a server supports it.
|
|
63
|
+
--
|
|
64
|
+
-- **Nothing expires a row.** An entry stays until its player says otherwise, so
|
|
65
|
+
-- a client that vanishes mid-track leaves one behind and `minutesAgo` is what
|
|
66
|
+
-- tells a reader how old it is. That is the honest reading of the protocol —
|
|
67
|
+
-- which describes `minutesAgo` as "last update" and leaves the reader to judge —
|
|
68
|
+
-- and the alternative, a timeout, would be this server inventing a rule about
|
|
69
|
+
-- how long a track is that no client agrees with.
|
|
70
|
+
CREATE TABLE now_playing (
|
|
71
|
+
player_id INTEGER PRIMARY KEY REFERENCES player(id),
|
|
72
|
+
track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE,
|
|
73
|
+
updated_at TEXT NOT NULL,
|
|
74
|
+
state TEXT,
|
|
75
|
+
position_ms INTEGER,
|
|
76
|
+
playback_rate REAL
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- The queue, and there is one of it.
|
|
80
|
+
--
|
|
81
|
+
-- `savePlayQueue`/`getPlayQueue` are per *user*, and this server has one — so
|
|
82
|
+
-- the row is single, like `playlist_sequence`'s counter, and for the same
|
|
83
|
+
-- reason: a table that could hold two rows would be a table where a caller has
|
|
84
|
+
-- to know which one it meant.
|
|
85
|
+
--
|
|
86
|
+
-- `current_index` is the protocol's `currentIndex` — a 0-based position in the
|
|
87
|
+
-- entries — and `getPlayQueue`, which addresses the current track by *id*
|
|
88
|
+
-- instead, is answered by looking that id up at this position. The two endpoints
|
|
89
|
+
-- read one queue because the extension exists to fix the older one's ambiguity
|
|
90
|
+
-- (a queue may hold the same song twice), not to keep a second queue.
|
|
91
|
+
CREATE TABLE play_queue (
|
|
92
|
+
only_row INTEGER PRIMARY KEY CHECK (only_row = 1),
|
|
93
|
+
current_index INTEGER,
|
|
94
|
+
position_ms INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
changed_at TEXT NOT NULL,
|
|
96
|
+
changed_by TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
-- A song's place in the queue. `position` is the order, laid out from zero by
|
|
100
|
+
-- every save — and a track deleted by a sweep leaves its number unused, exactly
|
|
101
|
+
-- as it does in `playlist_track`. That is what makes the number stable, and the
|
|
102
|
+
-- stability is load-bearing: the queue's current track is stored *as* one of
|
|
103
|
+
-- these numbers (031), so it has to mean the same seat after a deletion as it
|
|
104
|
+
-- did before one.
|
|
105
|
+
CREATE TABLE play_queue_entry (
|
|
106
|
+
position INTEGER PRIMARY KEY,
|
|
107
|
+
track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE
|
|
108
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- The queue's current track is a seat, not an index, and what plays add up to.
|
|
2
|
+
--
|
|
3
|
+
-- Three corrections to `030_history.sql`, all found by the two review axes of
|
|
4
|
+
-- the same change (task:2863) — one of them independently reproduced.
|
|
5
|
+
|
|
6
|
+
-- **`current_index` was a position read as an index.**
|
|
7
|
+
--
|
|
8
|
+
-- The column was meant as a position in the saved order, but `queue()` hands
|
|
9
|
+
-- back the entries that survived a sweep, compacted, and the API read one as the
|
|
10
|
+
-- other. A track deleted *ahead* of the current one shifts every ordinal after
|
|
11
|
+
-- it, so `getPlayQueue` announced a different song as playing; delete the
|
|
12
|
+
-- current track itself and the stored number ran past the end, so the field was
|
|
13
|
+
-- dropped while the queue was not empty — breaking the specification's own
|
|
14
|
+
-- "must ensure that `current` exists" for exactly the case it describes.
|
|
15
|
+
--
|
|
16
|
+
-- Reproduced against the module: queue `[tr:1…tr:5]` with `current=tr:3`,
|
|
17
|
+
-- `tr:1` deleted → `current: "tr:4"`.
|
|
18
|
+
--
|
|
19
|
+
-- `play_queue_entry.position` does not shift: a deleted entry leaves its number
|
|
20
|
+
-- unused, which is the whole reason the entries are numbered rather than
|
|
21
|
+
-- indexed. So this is the number to store, and `currentIndex` — the ordinal the
|
|
22
|
+
-- extension reports — is derived by counting the survivors before it.
|
|
23
|
+
ALTER TABLE play_queue RENAME COLUMN current_index TO current_position;
|
|
24
|
+
|
|
25
|
+
-- **What each track's plays add up to**, for the two fields the protocol puts on
|
|
26
|
+
-- every `Child`: `playCount` and `played`.
|
|
27
|
+
--
|
|
28
|
+
-- `play` is the history and stays the record of what happened. This is a rollup
|
|
29
|
+
-- of it, and it exists because the alternative is an aggregate per row in every
|
|
30
|
+
-- listing: `SONG_SELECT` feeds `getAlbum`, `getArtist`, `search3`, the folder
|
|
31
|
+
-- tree and both starred listings, and a correlated count there is one query
|
|
32
|
+
-- multiplied by whatever the listing returns — the cost this project has paid
|
|
33
|
+
-- for twice already (task:2806, task:2807).
|
|
34
|
+
CREATE TABLE track_play (
|
|
35
|
+
track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
|
|
36
|
+
play_count INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
played_at TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- **An index with no reader.**
|
|
41
|
+
--
|
|
42
|
+
-- Nothing orders or filters by `played_at`: a track's history is read by track
|
|
43
|
+
-- (`track_play` for the fields, `play_track` for the cascade), and no query asks
|
|
44
|
+
-- "what was played between these times" yet. An index nobody reads is space and
|
|
45
|
+
-- a write cost on every scrobble, so it goes until a query claims it.
|
|
46
|
+
DROP INDEX play_played_at;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Where the listener stopped.
|
|
2
|
+
--
|
|
3
|
+
-- The fourth part of the listener's own layer — playlists are what they
|
|
4
|
+
-- arranged, stars what they thought, plays what they did, and this is how far
|
|
5
|
+
-- they got. Nothing on disk states it, so no scan writes here, and what a scan
|
|
6
|
+
-- takes away goes through the cascade.
|
|
7
|
+
--
|
|
8
|
+
-- **Keyed by the song, with no id of its own.** One listener, one place per
|
|
9
|
+
-- track, which is the protocol's own rule in its own words: "if a bookmark
|
|
10
|
+
-- already exists for this file it will be overwritten". A second column of
|
|
11
|
+
-- identity would be a second bookmark per song, and a client with two resume
|
|
12
|
+
-- points and no way to choose between them.
|
|
13
|
+
--
|
|
14
|
+
-- `position_ms` is where in the *song* — which for a track cut out of a cue
|
|
15
|
+
-- image is the track's own timeline and not the image's, exactly as `duration`
|
|
16
|
+
-- is: a position is what a client seeks to, and it seeks within what it was
|
|
17
|
+
-- given.
|
|
18
|
+
CREATE TABLE bookmark (
|
|
19
|
+
track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
|
|
20
|
+
position_ms INTEGER NOT NULL,
|
|
21
|
+
comment TEXT,
|
|
22
|
+
-- Both, and they part company on the second write: a client that moves the
|
|
23
|
+
-- mark has not made a new one, and the difference is the only thing that says
|
|
24
|
+
-- whether a bookmark is the one somebody left or one they keep moving.
|
|
25
|
+
created_at TEXT NOT NULL,
|
|
26
|
+
changed_at TEXT NOT NULL
|
|
27
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- The order bookmarks are read in, indexed.
|
|
2
|
+
--
|
|
3
|
+
-- `bookmark/store.ts` reads the whole table `ORDER BY changed_at DESC, track_id`
|
|
4
|
+
-- — a list of where somebody stopped is a list of what they are in the middle
|
|
5
|
+
-- of, and the one they last listened to is the first — and `032_bookmarks.sql`
|
|
6
|
+
-- declared no index at all. So the listing sorted the table every time, which is
|
|
7
|
+
-- the shape `028_annotations.sql` names for its own three tables and `029` for
|
|
8
|
+
-- the ledger: a reader that orders by a column is a reader that wants it
|
|
9
|
+
-- indexed.
|
|
10
|
+
--
|
|
11
|
+
-- `track_id` is in the index and not only in the query because SQLite has to
|
|
12
|
+
-- break a tie by something, and a tie broken by a column the index does not
|
|
13
|
+
-- carry is a rowid walk per equal pair.
|
|
14
|
+
CREATE INDEX bookmark_changed ON bookmark (changed_at DESC, track_id);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- What the collection holds that is not a record, and who said so.
|
|
2
|
+
--
|
|
3
|
+
-- The contract asks for a junk filter that hides what is not music from the
|
|
4
|
+
-- default view and **never deletes anything** (requirements:47 §11). Two halves,
|
|
5
|
+
-- stored apart because they have different owners:
|
|
6
|
+
--
|
|
7
|
+
-- 1. `album.junk_reason` is *derived*, like every other column classify
|
|
8
|
+
-- writes: it is re-computed from the folder on every pass, so it follows
|
|
9
|
+
-- the disk and a rescan can change its mind. NULL means "a record".
|
|
10
|
+
-- 2. `junk_mark` is *said*, and only a person says it. It is what the
|
|
11
|
+
-- contract calls the allow/block edit, and it outlives everything: it is
|
|
12
|
+
-- keyed on the path and not on the album row, so a folder that vanishes
|
|
13
|
+
-- and comes back is still marked, and a mark for a folder that is gone is
|
|
14
|
+
-- kept rather than swept — a statement about a path does not stop being
|
|
15
|
+
-- true because the disk is unavailable.
|
|
16
|
+
--
|
|
17
|
+
-- The two are not equal partners: classify reads the marks and lets them win
|
|
18
|
+
-- over its own reading, which is why `source` is on the row. The rule the
|
|
19
|
+
-- project keeps for every derived field (Q15, brainstorm:190) is that the
|
|
20
|
+
-- *owner* of a field decides and never "whoever wrote last" — here the owner is
|
|
21
|
+
-- the person, and the scan is the default they override.
|
|
22
|
+
|
|
23
|
+
-- Why an album is not a record, or NULL when it is one. Written by classify.
|
|
24
|
+
ALTER TABLE album ADD COLUMN junk_reason TEXT;
|
|
25
|
+
|
|
26
|
+
CREATE TABLE junk_mark (
|
|
27
|
+
root_id INTEGER NOT NULL,
|
|
28
|
+
rel_path TEXT NOT NULL,
|
|
29
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('junk', 'trust')),
|
|
30
|
+
note TEXT,
|
|
31
|
+
marked_at TEXT NOT NULL,
|
|
32
|
+
PRIMARY KEY (root_id, rel_path)
|
|
33
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- The keys a client may present instead of a password, and the one that is not here.
|
|
2
|
+
--
|
|
3
|
+
-- The OpenSubsonic extension `apiKeyAuthentication` is not a parameter, it is a
|
|
4
|
+
-- pair of promises: a key that arrives is a whole credential, and a server that
|
|
5
|
+
-- accepts one **must** offer a way to see the keys it is accepting and to take
|
|
6
|
+
-- one back. This project honoured the first half from the beginning — the
|
|
7
|
+
-- parameter worked, once `auth.ts` stopped demanding `u` beside it — and had no
|
|
8
|
+
-- way at all to do the second: the key lived in a variable in `start.cmd`,
|
|
9
|
+
-- readable only by reading that file on the server, and revocable only by
|
|
10
|
+
-- editing it and restarting the daemon (task:2915).
|
|
11
|
+
--
|
|
12
|
+
-- So this is the registry that makes the second half true. A key added here can
|
|
13
|
+
-- be listed and revoked while the server is running, which is what taking a key
|
|
14
|
+
-- back from a device somebody lost has to mean.
|
|
15
|
+
--
|
|
16
|
+
-- **The environment's key is deliberately not in this table.** `FUNOTEKA_APIKEY`
|
|
17
|
+
-- stays exactly what it was — checked first, always valid — and it is the
|
|
18
|
+
-- credential that must survive a database. A key whose only copy lives in the
|
|
19
|
+
-- database cannot recover that database, and a bootstrap credential that
|
|
20
|
+
-- `revoke` could silently undo on the next restart would be worse than none. The
|
|
21
|
+
-- CLI reports it as what it is and says where it is revoked.
|
|
22
|
+
--
|
|
23
|
+
-- A revoked row is kept rather than deleted. Its `secret` stays under the unique
|
|
24
|
+
-- index, so a key that was taken back cannot be added again by accident and
|
|
25
|
+
-- quietly become valid — and "when was this taken back" is a question the
|
|
26
|
+
-- registry should be able to answer, which a deleted row cannot.
|
|
27
|
+
CREATE TABLE api_key (
|
|
28
|
+
id INTEGER PRIMARY KEY,
|
|
29
|
+
-- What the person called it: "the tablet", "Symfonium on the phone". A key
|
|
30
|
+
-- nobody can tell from another is a key nobody dares revoke.
|
|
31
|
+
label TEXT NOT NULL,
|
|
32
|
+
secret TEXT NOT NULL,
|
|
33
|
+
created_at TEXT NOT NULL,
|
|
34
|
+
revoked_at TEXT
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
-- Unique across revoked rows too — see the note above.
|
|
38
|
+
CREATE UNIQUE INDEX api_key_secret ON api_key (secret);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
-- The eight tags a song listing asks for, as one row a file.
|
|
2
|
+
--
|
|
3
|
+
-- A listing builds a `Child` per song, and eight of that payload's fields come
|
|
4
|
+
-- from `file_tag`: the genre, the track artist, the two advisories, and the four
|
|
5
|
+
-- ReplayGain numbers. **A page of five hundred songs costs 10.04 ms reading them
|
|
6
|
+
-- the obvious way — one correlated subquery per name, which is what the API did
|
|
7
|
+
-- — and 6.37 ms reading them off this row**, measured 2026-09-16 on a copy of the
|
|
8
|
+
-- live collection, the two statements interleaved in one run (task:2925).
|
|
9
|
+
--
|
|
10
|
+
-- **That shape is not the defect, and the measurement says so.** A correlated
|
|
11
|
+
-- scalar subquery here is one index seek through
|
|
12
|
+
-- `sqlite_autoindex_file_tag_1 (file_id, name, position)`, with `ORDER BY
|
|
13
|
+
-- position LIMIT 1` free, and an absent name costs a seek that finds nothing.
|
|
14
|
+
-- Every alternative was measured and every one is worse: the same eight as one
|
|
15
|
+
-- `json_group_object` is no faster and answers differently; a `LEFT JOIN` to a
|
|
16
|
+
-- derived pivot — the shape `ALBUM_GENRES` uses — cannot be restricted to the
|
|
17
|
+
-- page, so it materialises over the whole tag table and multiplies the page's
|
|
18
|
+
-- cost by an order of magnitude (51 ms against 11.29 in the run that measured
|
|
19
|
+
-- the alternatives); a covering index `(file_id, name, position, value)` is 13
|
|
20
|
+
-- times worse forced (the planner will not choose it: it gives up the free
|
|
21
|
+
-- ordering); and `WITHOUT ROWID` is 5 times worse. What is left is that eight
|
|
22
|
+
-- seeks per song are eight seeks per song.
|
|
23
|
+
--
|
|
24
|
+
-- So the values move out of the tag table, and a file's row is joined the way
|
|
25
|
+
-- `audio_probe` already is — one primary-key lookup on a table of one row per
|
|
26
|
+
-- file. The join is not quite free: a page with no tag columns at all is 6.56 ms
|
|
27
|
+
-- in the run that put the eight subqueries at 11.29.
|
|
28
|
+
--
|
|
29
|
+
-- **The rule is the API's own, copied exactly**, because these columns are what
|
|
30
|
+
-- a `Child` is shown: the *first value by position* of each name, trimmed of the
|
|
31
|
+
-- whitespace a tag can hide in, with an empty result treated as no value at all
|
|
32
|
+
-- so that a second line falls through to a first. `TRIM` is spelled with its
|
|
33
|
+
-- character set and not with SQLite's one-argument form, which removes spaces
|
|
34
|
+
-- and leaves a tab — the same trap `meta.ts` spells out beside `trimmed`.
|
|
35
|
+
--
|
|
36
|
+
-- Written by the tags stage from this migration onward (`tags/first.ts` holds
|
|
37
|
+
-- the live statement; this is the frozen backfill), so a file whose tags are
|
|
38
|
+
-- read again gets its row rewritten in the same transaction that rewrote the
|
|
39
|
+
-- tags. A file that has never been read has no row, and the join answers null —
|
|
40
|
+
-- which is what the subquery answered too.
|
|
41
|
+
CREATE TABLE file_tag_first (
|
|
42
|
+
file_id INTEGER PRIMARY KEY REFERENCES file(id) ON DELETE CASCADE,
|
|
43
|
+
genre TEXT,
|
|
44
|
+
track_artist TEXT,
|
|
45
|
+
advisory_itunes TEXT,
|
|
46
|
+
advisory_mp4 TEXT,
|
|
47
|
+
rg_track_gain TEXT,
|
|
48
|
+
rg_album_gain TEXT,
|
|
49
|
+
rg_track_peak TEXT,
|
|
50
|
+
rg_album_peak TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- The collection as it stands, once. From here the stage keeps it.
|
|
54
|
+
INSERT INTO file_tag_first (
|
|
55
|
+
file_id, genre, track_artist, advisory_itunes, advisory_mp4,
|
|
56
|
+
rg_track_gain, rg_album_gain, rg_track_peak, rg_album_peak
|
|
57
|
+
)
|
|
58
|
+
SELECT file_id,
|
|
59
|
+
MAX(CASE WHEN name = 'genre' THEN value END),
|
|
60
|
+
MAX(CASE WHEN name = 'artist' THEN value END),
|
|
61
|
+
MAX(CASE WHEN name = 'itunesadvisory' THEN value END),
|
|
62
|
+
MAX(CASE WHEN name = 'rtng' THEN value END),
|
|
63
|
+
MAX(CASE WHEN name = 'replaygain_track_gain' THEN value END),
|
|
64
|
+
MAX(CASE WHEN name = 'replaygain_album_gain' THEN value END),
|
|
65
|
+
MAX(CASE WHEN name = 'replaygain_track_peak' THEN value END),
|
|
66
|
+
MAX(CASE WHEN name = 'replaygain_album_peak' THEN value END)
|
|
67
|
+
FROM (
|
|
68
|
+
SELECT ft.file_id,
|
|
69
|
+
ft.name,
|
|
70
|
+
TRIM(ft.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288)) AS value,
|
|
71
|
+
ROW_NUMBER() OVER (PARTITION BY ft.file_id, ft.name ORDER BY ft.position) AS rn
|
|
72
|
+
FROM file_tag ft
|
|
73
|
+
WHERE ft.name IN ('genre', 'artist', 'itunesadvisory', 'rtng',
|
|
74
|
+
'replaygain_track_gain', 'replaygain_album_gain',
|
|
75
|
+
'replaygain_track_peak', 'replaygain_album_peak')
|
|
76
|
+
AND TRIM(ft.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288)) <> ''
|
|
77
|
+
)
|
|
78
|
+
WHERE rn = 1
|
|
79
|
+
GROUP BY file_id;
|
|
80
|
+
|
|
81
|
+
-- The `MAX` above is exact rather than a choice: `rn = 1` leaves one row per
|
|
82
|
+
-- (file, name), so the aggregate has nothing to choose between. That is the
|
|
83
|
+
-- whole reason for the window function — the cheaper-looking `MIN(position)`
|
|
84
|
+
-- beside a bare `value` reads the value out of whichever row the aggregate
|
|
85
|
+
-- happened to keep, which SQLite guarantees only while the subquery holds
|
|
86
|
+
-- exactly one aggregate. `albumGenres` in `meta.ts` declines that form at
|
|
87
|
+
-- length; this declines it for the same reason and pays the same few
|
|
88
|
+
-- milliseconds for it, once.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- What a mutation answered, kept by the key the caller gave it.
|
|
2
|
+
--
|
|
3
|
+
-- A control surface is called by an agent or a script over a network, and a
|
|
4
|
+
-- network gives two answers that look alike from the caller's side: the request
|
|
5
|
+
-- that was never received, and the answer that was lost on the way back. A
|
|
6
|
+
-- caller that retries cannot tell them apart, and for `config set` a retry is
|
|
7
|
+
-- harmless while for a rotation it is not. An `Idempotency-Key` header is how
|
|
8
|
+
-- the caller says "this is the same request", and this table is where the first
|
|
9
|
+
-- answer is kept so the second one can be given it instead of doing the work
|
|
10
|
+
-- twice.
|
|
11
|
+
--
|
|
12
|
+
-- In the database rather than in memory, and that is not a detail: `POST
|
|
13
|
+
-- /restart` exists, it is in this same surface, and a server that had just
|
|
14
|
+
-- restarted would have forgotten every key it had ever been given — which is
|
|
15
|
+
-- exactly the moment a client retries.
|
|
16
|
+
--
|
|
17
|
+
-- The body is stored as the text that was sent, with its status, because a
|
|
18
|
+
-- replay has to be indistinguishable from the answer it repeats: a caller that
|
|
19
|
+
-- got a different body the second time would be right to conclude the operation
|
|
20
|
+
-- ran twice.
|
|
21
|
+
CREATE TABLE admin_idempotency (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
method TEXT NOT NULL,
|
|
24
|
+
path TEXT NOT NULL,
|
|
25
|
+
status INTEGER NOT NULL,
|
|
26
|
+
body TEXT NOT NULL,
|
|
27
|
+
created_at TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
-- Pruning is by age, and age is the only thing it is ever asked for.
|
|
31
|
+
CREATE INDEX admin_idempotency_age ON admin_idempotency (created_at);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- The cue that plays an audio file, found by that file.
|
|
2
|
+
--
|
|
3
|
+
-- `cue.audio_file_id` is what every reader of a cue track joins on: the
|
|
4
|
+
-- inventory's performer column, its "audio files with no track or cue" counter,
|
|
5
|
+
-- and the artist stage's own binding of a cue to the file it came from. The
|
|
6
|
+
-- table carried only its own primary key, so each of those questions was a full
|
|
7
|
+
-- scan of `cue` — and one of them is asked once per track.
|
|
8
|
+
--
|
|
9
|
+
-- Measured on a 472-album collection, 5054 tracks against 302 cues: the
|
|
10
|
+
-- inventory's performer subquery cost 120.7 ms of the dump's 171.0 ms, because
|
|
11
|
+
-- every track read all 302 cues. With this index the same subquery is 28.4 ms
|
|
12
|
+
-- and the whole dump 72.7 ms.
|
|
13
|
+
--
|
|
14
|
+
-- **The review that found this attributed the cost to the wrong thing**, and the
|
|
15
|
+
-- correction is worth keeping: `GET /inventory` ran 1416 statements (three per
|
|
16
|
+
-- album) and the dump was read as "73% statement executions". Executing the same
|
|
17
|
+
-- three statements once for the whole collection — which is a real improvement
|
|
18
|
+
-- and is kept — bought only about 20%. The executions were expensive for the
|
|
19
|
+
-- rows they read, not for their number, and the rows were expensive because this
|
|
20
|
+
-- index was missing.
|
|
21
|
+
--
|
|
22
|
+
-- Nothing about the answers changes: this is an index, not a redefinition, so no
|
|
23
|
+
-- reader's output moves by a byte.
|
|
24
|
+
CREATE INDEX idx_cue_audio_file ON cue (audio_file_id);
|
package/dist/db/sweep.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables, in the order they may be emptied.
|
|
3
|
+
*
|
|
4
|
+
* `album` before `release`, because an album's `release_id` names a release and
|
|
5
|
+
* a release emptied first would leave the albums of that box behind. The rest
|
|
6
|
+
* is the order the two stages have always emptied their tables in, and it is
|
|
7
|
+
* free to be arbitrary: nothing constrains `file` and `folder`, which are
|
|
8
|
+
* joined by path rather than by key.
|
|
9
|
+
*/
|
|
10
|
+
const DERIVED = [
|
|
11
|
+
{
|
|
12
|
+
table: 'file',
|
|
13
|
+
lateStamp: false,
|
|
14
|
+
detach: [
|
|
15
|
+
{ action: 'delete', from: 'track', column: 'file_id' },
|
|
16
|
+
{ action: 'null', from: 'cue', column: 'audio_file_id' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{ table: 'folder', lateStamp: true, detach: [] },
|
|
20
|
+
{ table: 'scan_state', lateStamp: false, detach: [] },
|
|
21
|
+
{ table: 'album', lateStamp: true, detach: [] },
|
|
22
|
+
{
|
|
23
|
+
table: 'release',
|
|
24
|
+
lateStamp: true,
|
|
25
|
+
// Belt and braces, and said plainly rather than dressed up: nothing reaches
|
|
26
|
+
// this today. Every album that outlives the sweep above was re-derived from
|
|
27
|
+
// a surviving folder on this run, and re-deriving an album writes its
|
|
28
|
+
// `release_id` itself — so no surviving album can still name a doomed
|
|
29
|
+
// release. Removing this changes no test. It stays because the foreign key
|
|
30
|
+
// is declared without a cascade, and the cost of being wrong about the
|
|
31
|
+
// reachability is a run that fails on its last step.
|
|
32
|
+
detach: [{ action: 'null', from: 'album', column: 'release_id' }],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
/** What a walk leaves behind, and what the scan takes away when it is gone. */
|
|
36
|
+
export const SWEPT_BY_SCAN = ['file', 'folder', 'scan_state'];
|
|
37
|
+
/** What classification derives from the folders the walk left standing. */
|
|
38
|
+
export const SWEPT_BY_CLASSIFY = ['album', 'release'];
|
|
39
|
+
/** `root_id = ? AND <the stamp is not this run's>`, bound in that order. */
|
|
40
|
+
function vanished(derived) {
|
|
41
|
+
return derived.lateStamp
|
|
42
|
+
? 'root_id = ? AND (last_seen_run_id IS NULL OR last_seen_run_id <> ?)'
|
|
43
|
+
: 'root_id = ? AND last_seen_run_id <> ?';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A statement releasing one reference, scoped by the rows that are going.
|
|
47
|
+
*
|
|
48
|
+
* The table and the column are this module's own literals — never a caller's
|
|
49
|
+
* string and never a name off disk — which is what makes interpolating them
|
|
50
|
+
* here the same kind of statement the sweep was written as before.
|
|
51
|
+
*/
|
|
52
|
+
function releaseSql(detach, derived) {
|
|
53
|
+
const doomed = `(SELECT id FROM ${derived.table} WHERE ${vanished(derived)})`;
|
|
54
|
+
return detach.action === 'delete'
|
|
55
|
+
? `DELETE FROM ${detach.from} WHERE ${detach.column} IN ${doomed}`
|
|
56
|
+
: `UPDATE ${detach.from} SET ${detach.column} = NULL WHERE ${detach.column} IN ${doomed}`;
|
|
57
|
+
}
|
|
58
|
+
export function prepareSweep(db, tables) {
|
|
59
|
+
const owned = DERIVED.filter((derived) => tables.includes(derived.table));
|
|
60
|
+
const steps = owned.map((derived) => ({
|
|
61
|
+
markSeen: db.prepare(`UPDATE ${derived.table} SET last_seen_run_id = ?
|
|
62
|
+
WHERE root_id = ? AND (rel_path = ? OR substr(rel_path, 1, ?) = ?)`),
|
|
63
|
+
// What a row of this table says before a held path is stamped, so the stamp
|
|
64
|
+
// can be handed back afterwards. Read and written with the same matcher as
|
|
65
|
+
// `markSeen`, so the two cannot come to disagree about which rows are which.
|
|
66
|
+
// Keyed by the natural key rather than by `id`, which `folder` and
|
|
67
|
+
// `scan_state` do not have: what every table here shares is `root_id` and
|
|
68
|
+
// `rel_path`, which is why the matcher is spelled that way in the first place.
|
|
69
|
+
remember: db.prepare(`SELECT rel_path, last_seen_run_id AS stamp FROM ${derived.table}
|
|
70
|
+
WHERE root_id = ? AND (rel_path = ? OR substr(rel_path, 1, ?) = ?)`),
|
|
71
|
+
restore: db.prepare(`UPDATE ${derived.table} SET last_seen_run_id = ? WHERE root_id = ? AND rel_path = ?`),
|
|
72
|
+
detach: derived.detach.map((detach) => db.prepare(releaseSql(detach, derived))),
|
|
73
|
+
drop: db.prepare(`DELETE FROM ${derived.table} WHERE ${vanished(derived)}`),
|
|
74
|
+
}));
|
|
75
|
+
return {
|
|
76
|
+
run(rootId, runId, kept = [], held = []) {
|
|
77
|
+
for (const step of steps) {
|
|
78
|
+
// Before this table's rows are judged, not after: a row under a path the
|
|
79
|
+
// walk could not enter has to say it was seen, or the delete below
|
|
80
|
+
// reads it as gone. Each table is stamped before its own delete, which
|
|
81
|
+
// is all the ordering the amnesty needs — nothing else reads the stamp
|
|
82
|
+
// of another table mid-sweep.
|
|
83
|
+
for (const path of kept) {
|
|
84
|
+
const below = `${path}/`;
|
|
85
|
+
step.markSeen.run(runId, rootId, path, below.length, below);
|
|
86
|
+
}
|
|
87
|
+
// A held path is stamped for the same reason and unstamped for the
|
|
88
|
+
// opposite one: it has not vanished, and this run did not look at it.
|
|
89
|
+
// What survives the delete is the row; what must not survive is the
|
|
90
|
+
// claim, because that claim is the scope every stage selects its work
|
|
91
|
+
// by. Restored after the delete rather than instead of it — a row whose
|
|
92
|
+
// stamp says some older run is a row this delete would take.
|
|
93
|
+
const wasSaid = [];
|
|
94
|
+
for (const path of held) {
|
|
95
|
+
const below = `${path}/`;
|
|
96
|
+
for (const row of step.remember.all(rootId, path, below.length, below)) {
|
|
97
|
+
wasSaid.push(row);
|
|
98
|
+
}
|
|
99
|
+
step.markSeen.run(runId, rootId, path, below.length, below);
|
|
100
|
+
}
|
|
101
|
+
for (const statement of step.detach)
|
|
102
|
+
statement.run(rootId, runId);
|
|
103
|
+
step.drop.run(rootId, runId);
|
|
104
|
+
for (const row of wasSaid)
|
|
105
|
+
step.restore.run(row.stamp, rootId, row.rel_path);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|