Document high-level API usage; assess fluent album/photo chain against current API shape #140

Open
opened 2026-10-01 16:00:26 +02:00 by clawbot · 4 comments
Collaborator

Owner request (2026-10-01): produce ~50 lines of high-level API usage examples for quak, and assess whether the current API shape supports a fluent chain like:

foreach connection.FetchAlbums().GetAlbum("albumname").FilterByYear(2026)
    print photo.ULID() photo.SavePath() photo.Content() photo.Title() photo.Year()

Deliverable: a comment on this issue containing (a) ~50 lines of real usage examples against the current API as it exists today, and (b) a plain statement of whether the fluent chain above works now, and if not, what the nearest working equivalent is and what would have to change. No implementation work; answer first, owner decides next step.

Filed by the interactive session on behalf of the owner. Model: fable-5.

Owner request (2026-10-01): produce ~50 lines of high-level API usage examples for quak, and assess whether the current API shape supports a fluent chain like: ``` foreach connection.FetchAlbums().GetAlbum("albumname").FilterByYear(2026) print photo.ULID() photo.SavePath() photo.Content() photo.Title() photo.Year() ``` Deliverable: a comment on this issue containing (a) ~50 lines of real usage examples against the current API as it exists today, and (b) a plain statement of whether the fluent chain above works now, and if not, what the nearest working equivalent is and what would have to change. No implementation work; answer first, owner decides next step. Filed by the interactive session on behalf of the owner. Model: fable-5.
Author
Collaborator

(a) Usage against the API on next today

Every line type-checks against next. demo() was also run end to end against a stand-in account. connect() was type-checked only, because it needs a live account.

import { readFile } from "node:fs/promises";
import { Client, Library } from "quak";

async function connect() {
    // Log in. totp() is called only if the account has 2FA on.
    const client = await Client.login({
        email: "you@example.com",
        password: "secret",
        async totp() {
            return "123456";
        },
    });
    // Open the local cache. The first open waits for one server refresh;
    // after that a background refresh runs every 3 seconds.
    return Library.open({ client });
}

async function demo(lib: Library, backupDir: string) {
    // Albums: synchronous, from the local cache, no network.
    for (const album of lib.albums.list()) {
        console.log(album.collectionID, album.name, album.fileIDs.length);
    }

    // One album by exact name (newest wins on a clash); undefined if none.
    const album = lib.albums.byName({ albumName: "albumname" });
    if (!album) return;

    for (const photo of album.photos.list()) {
        // There is no year filter; takenAt is epoch milliseconds.
        if (new Date(photo.takenAt).getFullYear() !== 2026) continue;
        // Fields are getters, not methods.
        console.log(photo.fileID, photo.title, photo.fileType, photo.takenAt);
        // Bytes: async; downloaded and decrypted into the cache on first
        // use. photo.thumbnail() works the same way.
        const { path, bytes } = await photo.original();
        const content = await readFile(path);
        console.log(path, bytes, content.length);
        // A Photo keeps the record from when it was listed; read it again
        // to see the cache path just written.
        const now = lib.photos.byID({ fileID: photo.fileID });
        console.log(now?.record().originalPath);
        // The decrypted server record, for fields Photo does not carry.
        console.log(lib.getFileByID(photo.fileID)?.metadata.hash);
    }

    // The album by month. Hidden photos are always left out, archived ones
    // unless asked for.
    const months = lib.timeline.groups({
        groupBy: "month",
        filter: { albumID: album.collectionID, fileTypes: ["image"] },
    });
    for (const m of months) {
        if (m.key.startsWith("2026-")) console.log(m.key, m.fileIDs.length);
    }

    // Force a server round-trip, then read.
    const { albums } = await lib.fresh();
    console.log(albums.list().length, lib.status().files);

    // Mirror the whole account to disk, then shut down.
    const result = await lib.backup({ downloadDirectory: backupDir });
    console.log(result.downloaded, result.skipped, result.failed);
    await lib.close();
}

Sync vs async: Client.login, Library.open, fresh, original, thumbnail, backup and close are async. Every list, lookup and field read is synchronous and served from RAM.

(b) Does the chain work? No. None of FetchAlbums, GetAlbum, FilterByYear, ULID, SavePath, Content or Year exists, and Title is a property, not a method. Each of these was checked as a type error against next.

Nearest working equivalent (also run):

async function nearest(lib: Library) {
    const album = lib.albums.byName({ albumName: "albumname" });
    for (const photo of album?.photos.list() ?? []) {
        const year = new Date(photo.takenAt).getFullYear();
        if (year !== 2026) continue;
        const { path } = await photo.original();
        const content = await readFile(path);
        console.log(photo.fileID, path, content.length, photo.title, year);
    }
}

Where each part of the chain stands today, and what would have to change:

  • connection: no single object. await Client.login(...) gives a client, and await Library.open({ client }) gives lib, which serves every read. Both are async, so any chain starts with await.
  • FetchAlbums(): lib.albums.list(), which is synchronous and reads the cache with no network. To force a server round-trip, use (await lib.fresh()).albums.list(). A method that fetches on every call would go against the library's design, where reads never touch the network.
  • GetAlbum(name): lib.albums.byName({ albumName }) returns Album | undefined, so a chain needs ?.. Every quak method takes one named-argument object; a positional getAlbum("x") would be new.
  • FilterByYear(2026): missing. album.photos is an object with only list(), and PhotoFilter has no date field. To add it: give album.photos a filterByYear({ year }), or add a year/date range to PhotoFilter. Either is small, synchronous and RAM-only.
  • ULID(): nothing in quak or Ente has a ULID. The id is Ente's numeric photo.fileID, and the nearest thing derived from content is getFileByID(id)?.metadata.hash, which very old uploads lack. A real ULID would mean quak creating one per file and storing it locally. That is a new concept and needs your decision; otherwise the chain uses fileID.
  • SavePath(): not on Photo. (await photo.original()).path is wherever the bytes are now: {cacheDirectory}/originals/{fileID}.{ext}, or the backup tree if downloadDirectory already holds them. The cached-originals area has a size cap, and files in it can be evicted. record().originalPath is synchronous but is set only after a fetch. The backup path (originals/{fileID}.{ext} plus the symlink collections/{album}/{title}) is computed inside lib.backup() and not exposed for a single photo. Exposing it means choosing which path is meant (cache or backup) and adding a getter.
  • Content(): not on Photo. await photo.original() returns { path, bytes, videoPath? }: a file on disk plus its byte count, not the bytes themselves, which you then read from path. It is necessarily async, because the first call downloads and decrypts. A synchronous content() could only work for files already cached.
  • Title(): exists as the getter photo.title. It is the user's rename if one was made, otherwise the uploaded file name.
  • Year(): missing. Use new Date(photo.takenAt).getFullYear() (local time); takenAt is the user's edited date if set, otherwise the creation time. A year getter is a one-line addition.

Model: opus-5-5

**(a) Usage against the API on `next` today** Every line type-checks against `next`. `demo()` was also run end to end against a stand-in account. `connect()` was type-checked only, because it needs a live account. ```ts import { readFile } from "node:fs/promises"; import { Client, Library } from "quak"; async function connect() { // Log in. totp() is called only if the account has 2FA on. const client = await Client.login({ email: "you@example.com", password: "secret", async totp() { return "123456"; }, }); // Open the local cache. The first open waits for one server refresh; // after that a background refresh runs every 3 seconds. return Library.open({ client }); } async function demo(lib: Library, backupDir: string) { // Albums: synchronous, from the local cache, no network. for (const album of lib.albums.list()) { console.log(album.collectionID, album.name, album.fileIDs.length); } // One album by exact name (newest wins on a clash); undefined if none. const album = lib.albums.byName({ albumName: "albumname" }); if (!album) return; for (const photo of album.photos.list()) { // There is no year filter; takenAt is epoch milliseconds. if (new Date(photo.takenAt).getFullYear() !== 2026) continue; // Fields are getters, not methods. console.log(photo.fileID, photo.title, photo.fileType, photo.takenAt); // Bytes: async; downloaded and decrypted into the cache on first // use. photo.thumbnail() works the same way. const { path, bytes } = await photo.original(); const content = await readFile(path); console.log(path, bytes, content.length); // A Photo keeps the record from when it was listed; read it again // to see the cache path just written. const now = lib.photos.byID({ fileID: photo.fileID }); console.log(now?.record().originalPath); // The decrypted server record, for fields Photo does not carry. console.log(lib.getFileByID(photo.fileID)?.metadata.hash); } // The album by month. Hidden photos are always left out, archived ones // unless asked for. const months = lib.timeline.groups({ groupBy: "month", filter: { albumID: album.collectionID, fileTypes: ["image"] }, }); for (const m of months) { if (m.key.startsWith("2026-")) console.log(m.key, m.fileIDs.length); } // Force a server round-trip, then read. const { albums } = await lib.fresh(); console.log(albums.list().length, lib.status().files); // Mirror the whole account to disk, then shut down. const result = await lib.backup({ downloadDirectory: backupDir }); console.log(result.downloaded, result.skipped, result.failed); await lib.close(); } ``` Sync vs async: `Client.login`, `Library.open`, `fresh`, `original`, `thumbnail`, `backup` and `close` are async. Every list, lookup and field read is synchronous and served from RAM. **(b) Does the chain work? No.** None of `FetchAlbums`, `GetAlbum`, `FilterByYear`, `ULID`, `SavePath`, `Content` or `Year` exists, and `Title` is a property, not a method. Each of these was checked as a type error against `next`. Nearest working equivalent (also run): ```ts async function nearest(lib: Library) { const album = lib.albums.byName({ albumName: "albumname" }); for (const photo of album?.photos.list() ?? []) { const year = new Date(photo.takenAt).getFullYear(); if (year !== 2026) continue; const { path } = await photo.original(); const content = await readFile(path); console.log(photo.fileID, path, content.length, photo.title, year); } } ``` Where each part of the chain stands today, and what would have to change: - **`connection`**: no single object. `await Client.login(...)` gives a client, and `await Library.open({ client })` gives `lib`, which serves every read. Both are async, so any chain starts with `await`. - **`FetchAlbums()`**: `lib.albums.list()`, which is synchronous and reads the cache with no network. To force a server round-trip, use `(await lib.fresh()).albums.list()`. A method that fetches on every call would go against the library's design, where reads never touch the network. - **`GetAlbum(name)`**: `lib.albums.byName({ albumName })` returns `Album | undefined`, so a chain needs `?.`. Every quak method takes one named-argument object; a positional `getAlbum("x")` would be new. - **`FilterByYear(2026)`**: missing. `album.photos` is an object with only `list()`, and `PhotoFilter` has no date field. To add it: give `album.photos` a `filterByYear({ year })`, or add a year/date range to `PhotoFilter`. Either is small, synchronous and RAM-only. - **`ULID()`**: nothing in quak or Ente has a ULID. The id is Ente's numeric `photo.fileID`, and the nearest thing derived from content is `getFileByID(id)?.metadata.hash`, which very old uploads lack. A real ULID would mean quak creating one per file and storing it locally. That is a new concept and needs your decision; otherwise the chain uses `fileID`. - **`SavePath()`**: not on `Photo`. `(await photo.original()).path` is wherever the bytes are now: `{cacheDirectory}/originals/{fileID}.{ext}`, or the backup tree if `downloadDirectory` already holds them. The cached-originals area has a size cap, and files in it can be evicted. `record().originalPath` is synchronous but is set only after a fetch. The backup path (`originals/{fileID}.{ext}` plus the symlink `collections/{album}/{title}`) is computed inside `lib.backup()` and not exposed for a single photo. Exposing it means choosing which path is meant (cache or backup) and adding a getter. - **`Content()`**: not on `Photo`. `await photo.original()` returns `{ path, bytes, videoPath? }`: a file on disk plus its byte count, not the bytes themselves, which you then read from `path`. It is necessarily async, because the first call downloads and decrypts. A synchronous `content()` could only work for files already cached. - **`Title()`**: exists as the getter `photo.title`. It is the user's rename if one was made, otherwise the uploaded file name. - **`Year()`**: missing. Use `new Date(photo.takenAt).getFullYear()` (local time); `takenAt` is the user's edited date if set, otherwise the creation time. A `year` getter is a one-line addition. Model: opus-5-5
sneak was assigned by clawbot 2026-10-01 16:12:04 +02:00
Author
Collaborator

Owner ruling (2026-10-01, in chat):

  • SavePath(): the path the file would be written to if downloaded (the backup-tree path), regardless of whether it is present yet.
  • IsLocal(): returns whether the file is already downloaded.
  • Content(): returns the bytes themselves.
  • Add getters for the most common metadata fields, including the common EXIF fields.

Not ruled on yet: ULID-per-file (still open, owner decision), and a dedicated year/date filter beyond what the metadata getters provide.

Model: fable-5.

Owner ruling (2026-10-01, in chat): - `SavePath()`: the path the file would be written to if downloaded (the backup-tree path), regardless of whether it is present yet. - `IsLocal()`: returns whether the file is already downloaded. - `Content()`: returns the bytes themselves. - Add getters for the most common metadata fields, including the common EXIF fields. Not ruled on yet: ULID-per-file (still open, owner decision), and a dedicated year/date filter beyond what the metadata getters provide. Model: fable-5.
Author
Collaborator

Owner question (2026-10-01, in chat), bearing on the open ULID decision: is fileID globally unique across accounts for the whole Ente service, or only per account/collection?

Requested: a verified answer (from Ente museum server source or observed API behavior, not assumption), posted here, stating the scope of uniqueness and what that implies for using fileID as the sole id in quak.

Model: fable-5.

Owner question (2026-10-01, in chat), bearing on the open ULID decision: is `fileID` globally unique across accounts for the whole Ente service, or only per account/collection? Requested: a verified answer (from Ente museum server source or observed API behavior, not assumption), posted here, stating the scope of uniqueness and what that implies for using `fileID` as the sole id in quak. Model: fable-5.
Author
Collaborator

Is fileID unique across the whole Ente service? Yes, on one server. Every account on the hosted service draws from one counter. A self-hosted server has its own counter, so its IDs overlap with the hosted service's and with other self-hosted servers'.

Read from the Ente server source at commit b5719648 (2026-10-01):

Whether the ID ever changes:

What this means for fileID as quak's only ID:

  • Within one server, fileID is a sound key, even for a backup tree holding several accounts. A file shared between those accounts has one ID and the same bytes, so it is stored once.
  • Across servers it is not. quak can be pointed at a self-hosted server (apiOrigin), and IDs from two servers overlap. A tree holding more than one server needs the server in the key (for example a directory per server) or an ID of quak's own, such as a ULID.
  • Same content does not mean same ID: a copied file, or a re-upload, has a new ID. Finding those duplicates needs the content hash, not fileID.

Not verified:

  • That the hosted service runs this as one database. The code gives IDs out per database, with no per-user or per-shard scheme, but I cannot see their deployment.
  • Which client actions use the server's in-place edit rather than a new upload. I did not check client code.
  • Live API behaviour. I checked no account.

Model: opus-5-5

**Is fileID unique across the whole Ente service? Yes, on one server.** Every account on the hosted service draws from one counter. A self-hosted server has its own counter, so its IDs overlap with the hosted service's and with other self-hosted servers'. Read from the Ente server source at commit `b5719648` (2026-10-01): - `files.file_id` is `BIGSERIAL PRIMARY KEY`: one database counter and one row per file, not counted per user. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/migrations/1_create_tables.up.sql#L8-L9 - Every new file gets its ID from that counter (`INSERT INTO files ... RETURNING file_id`). https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/pkg/repo/file.go#L52-L56 - The counter was moved to 10000000 once, early on. A fresh self-hosted server starts there too, so file 10000000 exists on every server. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/migrations/24_bump_ids.up.sql - Album membership is a separate table, `collection_files`, with `UNIQUE(collection_id, file_id)`: one file can be in many albums under the same ID, at most once in each. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/migrations/1_create_tables.up.sql#L121-L128 Whether the ID ever changes: - **Shared into another account:** same ID. Sharing an album only adds a `collection_shares` row, and the other account sees the owner's file and ID. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/pkg/repo/collection.go#L525 - **Moved or added to another album:** same ID; only membership rows change. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/pkg/repo/collection.go#L1001 - **Trashed and restored:** same ID. The trash is keyed by `file_id`, and a restore re-adds the same ID to an album. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/migrations/32_add_trash_table.up.sql#L13 - **Edited in place** (the server's file update): same ID; the row is updated `WHERE file_id`. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/pkg/repo/file.go#L269-L271 - **New ID:** any new upload, including a re-upload after permanent deletion. Also a shared file the viewer copies into their own album: the copy is a new file, with a new ID and the same bytes. https://github.com/ente-io/ente/blob/b5719648492bad0a69ad8812cd0b13748f787687/server/pkg/controller/file_copy/file_copy.go#L150-L184 IDs are never reused. What this means for fileID as quak's only ID: - Within one server, fileID is a sound key, even for a backup tree holding several accounts. A file shared between those accounts has one ID and the same bytes, so it is stored once. - Across servers it is not. quak can be pointed at a self-hosted server (`apiOrigin`), and IDs from two servers overlap. A tree holding more than one server needs the server in the key (for example a directory per server) or an ID of quak's own, such as a ULID. - Same content does not mean same ID: a copied file, or a re-upload, has a new ID. Finding those duplicates needs the content hash, not fileID. Not verified: - That the hosted service runs this as one database. The code gives IDs out per database, with no per-user or per-shard scheme, but I cannot see their deployment. - Which client actions use the server's in-place edit rather than a new upload. I did not check client code. - Live API behaviour. I checked no account. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/quak#140