Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -4156,7 +4156,7 @@ A deck is a persistent, ordered list of games and cards the user keeps on the de

Every deck has a twelve-character ID drawn from the Crockford base32 alphabet (digits and letters without I, L, O and U). Core stores and shows it lower-case and matches it without regard to case; IDs of eight characters from older decks are also accepted, and may use any digit or letter. The same ID names the deck on the device, in its `user:deck:<id>` tag, on a card and on the account.

A deck opens as a playlist from ZapScript with `**playlist.open:deck://<id>` (and `playlist.load` and `playlist.play` accept the same argument), from the API with `decks.open`, or by tapping the deck's ZapLink. A deck reached through its ZapLink is cached on the device as a read-only copy, so it opens offline next time; a deck the device already owns opens its own copy instead. The playlist ID is `ZON-<id>` whichever way the deck was opened, so reopening the deck that is playing keeps its position.
A deck opens as a playlist from ZapScript with `**playlist.open:deck://<id>` (and `playlist.load` and `playlist.play` accept the same argument), from the API with `decks.open`, or by tapping a ZapLink that serves it. Any ZapLink, on any host, whose ZapScript is a single `playlist.open`, `playlist.play` or `playlist.load` command carrying a JSON playlist is kept on the device as a read-only deck, so it opens offline next time, shows in the deck list and has its games tagged. The copy is known by the link it was fetched from and gets a deck ID minted on this device; nothing in the link or the playlist is used as its ID, so it can never replace or stand in for a deck the user owns. At most 200 decks are kept from links; past that the one fetched longest ago is removed. A deck made on the device opens as the playlist ID `deck://<id>`, and a deck kept from a link opens as the `id` its playlist was served with, so reopening the deck that is playing keeps its position whichever way it was opened. A link the linked account vouches for as the user's own runs as served and is not kept as a copy.

A deck item is either a `script` (a name and the ZapScript it runs) or a `card` (an online card by ID, with its scripts and display metadata as pulled). A game added from the local library is stored as a script item: Core composes `**launch.title:<system>/<title>` with the file's disambiguating tags, so the item names the same game on any device, and keeps the file it was added from as the item's `media` so this device launches exactly that file. Card and deck `metadata` are stored as received and returned verbatim.

Expand Down
3 changes: 2 additions & 1 deletion pkg/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ type UserDBI interface {
UpdateDeck(deckID string, edit func(deck *Deck) error) (*Deck, error)
DeleteDeck(deckID string) (bool, error)
UpsertRemoteDeck(deck *Deck) (bool, error)
UpsertFetchedDeck(deck *Deck, maxCopies int) (FetchedDeckResult, error)
SetDeckItemAnchor(itemDBID int64, anchor *DeckItemAnchor) error
CountOwnedDecks() (int, error)
RenameDeck(oldID, newID string) error
Expand All @@ -1185,7 +1186,7 @@ type UserDBI interface {
DeleteDeckSync(deckIDs []string) error
ClearDeckSync() error
UpdateZapLinkHost(host string, zapscript int) error
GetZapLinkHost(host string) (bool, bool, error)
GetZapLinkHost(host string) (supported, found bool, err error)
GetSupportedZapLinkHosts() ([]string, error)
PruneExpiredZapLinkHosts(olderThan time.Duration) (int64, error)
UpdateZapLinkCache(url string, zapscript string) error
Expand Down
30 changes: 22 additions & 8 deletions pkg/database/decks.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,28 @@ type Deck struct {
Name string
Description string
SourceURL string
Metadata json.RawMessage
Items []DeckItem
DBID int64
CreatedAt int64
UpdatedAt int64
FetchedAt int64
ItemCount int
Owned bool
// PlaylistID is the playlist ID the deck's source served it under, kept
// verbatim so the local copy and the served playlist are one playlist.
// Empty for a deck that was not fetched from a link.
PlaylistID string
Metadata json.RawMessage
Items []DeckItem
DBID int64
CreatedAt int64
UpdatedAt int64
FetchedAt int64
ItemCount int
Owned bool
}

// FetchedDeckResult reports what keeping a deck fetched from a link did.
type FetchedDeckResult struct {
// DeckID is the ID the copy is kept under on this device.
DeckID string
// Evicted lists the fetched decks removed to make room.
Evicted []string
// Changed reports whether the stored deck changed.
Changed bool
}

// NewDeckID mints a deck ID from the operating system's random source:
Expand Down
115 changes: 107 additions & 8 deletions pkg/database/userdb/decks.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import (
"github.com/rs/zerolog/log"
)

const deckColumns = `DBID, DeckID, Name, Description, Owned, Metadata, SourceURL, FetchedAt, CreatedAt, UpdatedAt`
const deckColumns = `DBID, DeckID, Name, Description, Owned, Metadata, SourceURL, FetchedAt, CreatedAt, UpdatedAt,
PlaylistID`

const deckItemColumns = `DBID, DeckDBID, Position, Kind, Name, ZapScript, CardID, Scripts, Metadata,
SystemID, Path, MediaName, Tags, CreatedAt, UpdatedAt`
Expand Down Expand Up @@ -241,10 +242,10 @@ func (db *UserDB) UpsertRemoteDeck(deck *database.Deck) (bool, error) {
adoptRemoteDeckItemRows(stored, deck.Items)
if _, execErr := tx.ExecContext(ctx, `
update Decks set Name = ?, Description = ?, Owned = ?, Metadata = ?, SourceURL = ?,
FetchedAt = ?, UpdatedAt = ?
PlaylistID = ?, FetchedAt = ?, UpdatedAt = ?
where DBID = ?;`,
deck.Name, deck.Description, deck.Owned, string(deck.Metadata), deck.SourceURL,
now, now, existing.DBID); execErr != nil {
deck.PlaylistID, now, now, existing.DBID); execErr != nil {
return false, fmt.Errorf("failed to update remote deck: %w", execErr)
}
}
Expand All @@ -257,6 +258,102 @@ func (db *UserDB) UpsertRemoteDeck(deck *database.Deck) (bool, error) {
return changed, err
}

// UpsertFetchedDeck stores a deck fetched from a link as a read-only copy.
// The copy is known by deck.SourceURL alone: a link fetched before updates its
// stored copy, and any other link gets a deck ID minted on this device, so
// nothing the source serves can name, replace or hide another deck. At most
// maxCopies fetched decks are kept; past that the ones fetched longest ago
// are removed and reported, so their tags can be cleared.
func (db *UserDB) UpsertFetchedDeck(deck *database.Deck, maxCopies int) (database.FetchedDeckResult, error) {
var result database.FetchedDeckResult
if db.sql.Load() == nil {
return result, ErrNullSQL
}
if deck.SourceURL == "" {
return result, errors.New("fetched deck has no source URL")
}
var deckID string
err := db.sql.Load().QueryRowContext(db.ctx,
`select DeckID from Decks where Owned = 0 and SourceURL = ?;`, deck.SourceURL,
).Scan(&deckID)
isNew := errors.Is(err, sql.ErrNoRows)
switch {
case isNew:
if deckID, err = database.NewDeckID(); err != nil {
return result, fmt.Errorf("mint fetched deck ID: %w", err)
}
case err != nil:
return result, fmt.Errorf("failed to look up fetched deck: %w", err)
}
deck.DeckID, deck.Owned = deckID, false
changed, err := db.UpsertRemoteDeck(deck)
if err != nil {
return result, err
}
result.DeckID, result.Changed = deckID, changed
if isNew {
if result.Evicted, err = db.evictFetchedDecks(maxCopies); err != nil {
return result, err
}
}
return result, nil
}

// evictFetchedDecks removes the fetched decks beyond the keep most recently
// fetched and returns their IDs.
func (db *UserDB) evictFetchedDecks(keep int) ([]string, error) {
var evicted []string
err := db.deckTx(func(ctx context.Context, tx *sql.Tx, _ int64) (bool, error) {
dbids, deckIDs, err := sqlFetchedDecksBeyond(ctx, tx, keep)
if err != nil {
return false, err
}
for _, dbid := range dbids {
if _, err = tx.ExecContext(ctx, `delete from DeckItems where DeckDBID = ?;`, dbid); err != nil {
return false, fmt.Errorf("failed to delete fetched deck items: %w", err)
}
if _, err = tx.ExecContext(ctx, `delete from Decks where DBID = ?;`, dbid); err != nil {
return false, fmt.Errorf("failed to delete fetched deck: %w", err)
}
}
evicted = deckIDs
return len(dbids) > 0, nil
})
if err != nil {
return nil, err
}
return evicted, nil
}

func sqlFetchedDecksBeyond(
ctx context.Context, q deckQueryable, keep int,
) (dbids []int64, deckIDs []string, err error) {
rows, err := q.QueryContext(ctx, `
select DBID, DeckID from Decks where Owned = 0 and SourceURL != ''
order by FetchedAt desc, DBID desc limit -1 offset ?;`, keep)
if err != nil {
return nil, nil, fmt.Errorf("failed to query fetched decks: %w", err)
}
defer func() {
if closeErr := rows.Close(); closeErr != nil {
log.Warn().Err(closeErr).Msg("failed to close sql rows")
}
}()
for rows.Next() {
var dbid int64
var deckID string
if scanErr := rows.Scan(&dbid, &deckID); scanErr != nil {
return nil, nil, fmt.Errorf("failed to scan fetched deck: %w", scanErr)
}
dbids = append(dbids, dbid)
deckIDs = append(deckIDs, deckID)
}
if err = rows.Err(); err != nil {
return nil, nil, fmt.Errorf("failed to iterate fetched decks: %w", err)
}
return dbids, deckIDs, nil
}

// touchUnchangedRemoteDeck records that a deck was fetched when the arriving
// copy holds nothing the stored one does not. Rewriting the unchanged rows
// would move every item to a new row, drop the local file each item is linked
Expand Down Expand Up @@ -291,6 +388,7 @@ func (db *UserDB) touchUnchangedRemoteDeck(deck *database.Deck) (bool, error) {
func sameRemoteDeck(stored, arriving *database.Deck) bool {
if stored.Name != arriving.Name || stored.Description != arriving.Description ||
stored.SourceURL != arriving.SourceURL || stored.Owned != arriving.Owned ||
stored.PlaylistID != arriving.PlaylistID ||
!bytes.Equal(stored.Metadata, arriving.Metadata) ||
len(stored.Items) != len(arriving.Items) {
return false
Expand Down Expand Up @@ -435,11 +533,12 @@ func sqlCountOwnedDecks(ctx context.Context, q deckQueryable) (int, error) {

func sqlInsertDeck(ctx context.Context, q deckQueryable, deck *database.Deck) error {
err := q.QueryRowContext(ctx, `
insert into Decks(DeckID, Name, Description, Owned, Metadata, SourceURL, FetchedAt, CreatedAt, UpdatedAt)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
insert into Decks(DeckID, Name, Description, Owned, Metadata, SourceURL, FetchedAt, CreatedAt, UpdatedAt,
PlaylistID)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning DBID;`,
deck.DeckID, deck.Name, deck.Description, deck.Owned, string(deck.Metadata), deck.SourceURL,
deck.FetchedAt, deck.CreatedAt, deck.UpdatedAt,
deck.FetchedAt, deck.CreatedAt, deck.UpdatedAt, deck.PlaylistID,
).Scan(&deck.DBID)
if err != nil {
return fmt.Errorf("failed to insert deck: %w", err)
Expand Down Expand Up @@ -552,7 +651,7 @@ func scanDeck(scan func(dest ...any) error) (*database.Deck, error) {
var metadata string
if err := scan(
&deck.DBID, &deck.DeckID, &deck.Name, &deck.Description, &deck.Owned, &metadata,
&deck.SourceURL, &deck.FetchedAt, &deck.CreatedAt, &deck.UpdatedAt,
&deck.SourceURL, &deck.FetchedAt, &deck.CreatedAt, &deck.UpdatedAt, &deck.PlaylistID,
); err != nil {
return nil, err
}
Expand Down Expand Up @@ -604,7 +703,7 @@ func sqlListDecks(ctx context.Context, q deckQueryable) ([]database.Deck, error)
var metadata string
if scanErr := rows.Scan(
&deck.DBID, &deck.DeckID, &deck.Name, &deck.Description, &deck.Owned, &metadata,
&deck.SourceURL, &deck.FetchedAt, &deck.CreatedAt, &deck.UpdatedAt, &deck.ItemCount,
&deck.SourceURL, &deck.FetchedAt, &deck.CreatedAt, &deck.UpdatedAt, &deck.PlaylistID, &deck.ItemCount,
); scanErr != nil {
return nil, fmt.Errorf("failed to scan deck: %w", scanErr)
}
Expand Down
92 changes: 92 additions & 0 deletions pkg/database/userdb/decks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,95 @@ func TestUpdateDeck_UnlockedDeckStillEdits(t *testing.T) {
require.NoError(t, err)
assert.True(t, existed)
}

func TestUpsertFetchedDeck(t *testing.T) {
t.Parallel()
db, cleanup := setupTempUserDB(t)
t.Cleanup(cleanup)
require.NoError(t, db.CreateDeck(&database.Deck{DeckID: "aaaaaaaaaaaa", Name: "Mine", Owned: true}))

fetched := func(source, playlistID string) *database.Deck {
return &database.Deck{
// Whatever a caller or a source puts here is never used as the key.
DeckID: "aaaaaaaaaaaa", Owned: true,
Name: "Theirs", SourceURL: source, PlaylistID: playlistID,
Items: []database.DeckItem{scriptItem("A", "**a")},
}
}
const link = "https://decks.example/shelf/1?ref=a"

first, err := db.UpsertFetchedDeck(fetched(link, "party-list"), 10)
require.NoError(t, err)
assert.True(t, first.Changed)
assert.Empty(t, first.Evicted)
assert.True(t, database.IsMintedDeckID(first.DeckID))
assert.NotEqual(t, "aaaaaaaaaaaa", first.DeckID, "the ID is minted here, never taken from the fetch")
got, err := db.GetDeck(first.DeckID)
require.NoError(t, err)
assert.False(t, got.Owned, "a fetched deck is never an owned one")
assert.Equal(t, link, got.SourceURL)
assert.Equal(t, "party-list", got.PlaylistID)
mine, err := db.GetDeck("aaaaaaaaaaaa")
require.NoError(t, err)
assert.Equal(t, "Mine", mine.Name)
assert.True(t, mine.Owned)

// The same link is the same deck; a fetch that brings nothing new is not a change.
again, err := db.UpsertFetchedDeck(fetched(link, "party-list"), 10)
require.NoError(t, err)
assert.Equal(t, first.DeckID, again.DeckID)
assert.False(t, again.Changed)

// A playlist ID the source changed follows the fetch.
again, err = db.UpsertFetchedDeck(fetched(link, "renamed"), 10)
require.NoError(t, err)
assert.Equal(t, first.DeckID, again.DeckID)
assert.True(t, again.Changed)
got, err = db.GetDeck(first.DeckID)
require.NoError(t, err)
assert.Equal(t, "renamed", got.PlaylistID)

// Any other link, however alike, is another deck.
other, err := db.UpsertFetchedDeck(fetched(link+"b", "party-list"), 10)
require.NoError(t, err)
assert.NotEqual(t, first.DeckID, other.DeckID)

_, err = db.UpsertFetchedDeck(fetched("", "p"), 10)
require.Error(t, err, "a fetched deck is known by its source")
}

func TestUpsertFetchedDeckEvictsTheLongestUnfetched(t *testing.T) {
t.Parallel()
db, cleanup := setupTempUserDB(t)
t.Cleanup(cleanup)
require.NoError(t, db.CreateDeck(&database.Deck{DeckID: "aaaaaaaaaaaa", Name: "Mine", Owned: true}))

ids := make([]string, 0, 3)
for i := range 3 {
result, err := db.UpsertFetchedDeck(&database.Deck{
Name: "Theirs", SourceURL: fmt.Sprintf("https://decks.example/%d", i),
Items: []database.DeckItem{scriptItem("A", "**a")},
}, 3)
require.NoError(t, err)
require.Empty(t, result.Evicted)
ids = append(ids, result.DeckID)
// Fetch times are whole seconds; order the copies explicitly.
_, err = db.sql.Load().ExecContext(t.Context(),
`update Decks set FetchedAt = ? where DeckID = ?;`, 100+i, result.DeckID)
require.NoError(t, err)
}

result, err := db.UpsertFetchedDeck(&database.Deck{
Name: "Theirs", SourceURL: "https://decks.example/new",
Items: []database.DeckItem{scriptItem("A", "**a")},
}, 3)
require.NoError(t, err)
assert.Equal(t, []string{ids[0]}, result.Evicted, "the copy fetched longest ago makes room")
_, err = db.GetDeck(ids[0])
require.ErrorIs(t, err, database.ErrDeckNotFound)
_, err = db.GetDeck("aaaaaaaaaaaa")
require.NoError(t, err, "an owned deck is never evicted")
all, err := db.ListDecks()
require.NoError(t, err)
assert.Len(t, all, 4)
}
17 changes: 17 additions & 0 deletions pkg/database/userdb/migrations/20260919120000_deck_playlist_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin

-- A deck kept from a ZapLink is known by the link it came from, and opens as
-- the playlist ID its source served, so the copy and the served playlist are
-- the same playlist. A deck made on this device leaves PlaylistID empty.
alter table Decks add column PlaylistID text not null default '';

create unique index decks_fetched_source_idx on Decks (SourceURL) where Owned = 0 and SourceURL != '';

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
drop index decks_fetched_source_idx;
alter table Decks drop column PlaylistID;
-- +goose StatementEnd
2 changes: 1 addition & 1 deletion pkg/database/userdb/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ func sqlUpdateZapLinkHost(ctx context.Context, db *sql.DB, host string, zapscrip
return nil
}

func sqlGetZapLinkHost(ctx context.Context, db *sql.DB, host string) (found, zapScript bool, err error) {
func sqlGetZapLinkHost(ctx context.Context, db *sql.DB, host string) (supported, found bool, err error) {
row := db.QueryRowContext(ctx, `
SELECT ZapScript FROM ZapLinkHosts WHERE Host = ?;
`, host)
Expand Down
2 changes: 1 addition & 1 deletion pkg/database/userdb/userdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func (db *UserDB) UpdateZapLinkHost(host string, zapscript int) error {
return sqlUpdateZapLinkHost(db.ctx, db.sql.Load(), host, zapscript)
}

func (db *UserDB) GetZapLinkHost(host string) (found, zapScript bool, err error) {
func (db *UserDB) GetZapLinkHost(host string) (supported, found bool, err error) {
return sqlGetZapLinkHost(db.ctx, db.sql.Load(), host)
}

Expand Down
Loading
Loading