Skip to content

feat(materialize): :core:materialize — POST /api/materialize plumbing + domain model (#11) - #100

Merged
Adron merged 1 commit into
parity/queuefrom
issue/11-materialize-plumbing
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/11-materialize-plumbing

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #11. Part of epic #10. Foundation for #12, #13, #14, #15, #16.

The contract came from documentation, not guesses

https://interlinedlist.com/help/api/create-from — a human-written API reference the original
parity review did not have — gives the real shape, and the OpenAPI MaterializeResult schema is
richer than the bare source: string the epic warned about. Also pulled /help/create-from
(behaviour) and /help/api/lists-dsl (the twelve column types, corroborated by the server's own
400 message).

{ "target": "list | doc | both | message",
  "source": { "kind": "messages", "messageIds": [""] },
  "listConfig": {}, "docConfig": {}, "messageConfig": {} }

Two corrections to the issue text, both load-bearing:

  • The discriminator is source.kind, not source.type.
  • The issue says four source kinds; the API has fivedocument and docElements are
    separate.

Module home: :core:materialize

Registered in settings.gradle.kts, built like the other :core:* Android library modules, no
Compose and no Room. It is called from three feature modules, and no feature module in this repo
depends on another, so a shared module is the only correct home. I found no reason to prefer a
:feature: module.

Design

  • One sealed type over the whole matrix. Each destination carries only its own config, so a
    docConfig on a list-only conversion will not compile. MaterializeTarget.createsContent is
    false only for MESSAGE, because target: "message" returns a draft and writes nothing —
    which is exactly what Create from…: "To Message" prefills the composer instead of creating #16 is about.
  • The source is id-only by construction. The docs are explicit that the server re-fetches and
    authorizes every id and that "client-supplied cell values or body text are not trusted", so the
    type cannot carry content. Empty selections are rejected in init. There is a test on a
    rows-source body proving no cell values travel.
  • ListConfig.title is non-nullable — the server answers A list title is required (confirmed
    live).
  • sourceKey: null is sent explicitly. The shared Json runs explicitNulls = false, which would
    silently drop a null sourceKey — but an omitted key does not mean "user-added empty column".
    The DTO holds a JsonElement and writes JsonNull, with a test asserting the key is present and
    null.
  • The gate fails open. It blocks only when the account is positively known to be free, so an
    unreadable /api/user leaves the server as the real gate rather than locking out a subscriber
    whose status read happened to fail.

On not using safeApiCall

Error mapping reuses the shared AppError (so #12#16 get toUserMessage() / isSubscriptionGate
for free) but goes through its own mapper rather than safeApiCall, which drops code and decides
the upsell by looking for the word "subscription" in the message. Here a 403 is the subscriber
gate whatever the wording — except account_* codes, which no subscription would lift and which map
to Forbidden. bad_request/validation_failed map to Unknown(message) so the server's own words
("A list title is required") reach the user. This is the same underlying problem as #94.

Two things to confirm with the owner

  1. Is target: "message" subscriber-gated? The two docs contradict each other — the API
    reference says "Subscriber only: yes" for the whole endpoint; the help centre says "To Message
    posts through the normal composer… posting is free". Resolved defensively: the client does not
    pre-block the message target (it creates nothing, and blocking it would contradict a
    documented-free behaviour), but a server 403 still maps to SubscriptionRequired, so a free user
    reaches the upsell either way and no write occurs. Worst case a free user spends one wasted
    403 instead of getting an instant upsell. Not verifiable from here — the only live account
    available is a subscriber.
  2. The wire spelling of messageConfig.crossPostTargets. The reference gives display labels
    ("Bluesky, Mastodon, LinkedIn, X/Twitter") without pinning the strings. Used the lowercase values
    the API documents for crossPosts[].platform. Worst case this mis-sizes a draft's charLimit;
    nothing is created.

Also noted: messages → message is accepted by the API but hidden in the web UI. Left
representable — the type should encode what the API forbids, not what a UI chooses not to offer.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 816 tests repo-wide, 0
failures
(44 new). MockWebServer round-trip per destination asserting the exact body; all five
source kinds asserting only that kind's keys appear; every column type; the explicit-null
sourceKey; free-account confirm → SubscriptionRequired with server.requestCount == 0; gate
resolution from /api/user; a 403 folded back into the gate so a second confirm issues nothing;
full error-code mapping; and a 201 that omitted what the target promised treated as a failure rather
than an empty success.

Scope: only settings.gradle.kts, one dependency line in app/build.gradle.kts, and the new module.

Note for #15

#15's preview/confirm window will need the Compose plugin added to
core/materialize/build.gradle.kts if that window lands here rather than in each surface.
MaterializeOutcome is deliberately a plain sealed type rather than generic over the request,
because that window switches destination dynamically and a generic would force star-projections.

…ialize

"Create from…" is invoked from :feature:messages, :feature:lists and
:feature:documents, and no feature module in this repo depends on another,
so the shared foundation lands in a new :core:materialize module. It owns
its DTOs and its own Retrofit API built from the shared authed Retrofit,
like every other module here; it has no Room cache, because the endpoint
is a one-shot write whose result is authoritative.

Modelled from the published API reference (/help/api/create-from) rather
than the OpenAPI spec, which types `source` as a bare string:

- MaterializeRequest is one sealed type over the whole matrix — four
  destinations x five source kinds. Each destination carries only the
  config the API accepts for it, so a doc title cannot ride along on a
  list-only conversion. A list title is mandatory in the type because the
  server refuses without one ("A list title is required").
- MaterializeSource is id-only by construction: the server re-fetches and
  re-authorizes every id and rebuilds from its own data, so there is
  nowhere for client cell values to be smuggled through.
- To Message is a server call that returns a DRAFT and creates nothing;
  MaterializeTarget.createsContent records the difference so it is not
  discovered at runtime.
- A user-added column sends `sourceKey: null` explicitly (the shared Json
  runs with explicitNulls = false, which would otherwise drop the key and
  lose its meaning).

Errors map onto the shared AppError so the feature modules keep their
existing toUserMessage()/isSubscriptionGate handling. Unlike safeApiCall,
a 403 here is the subscriber gate whatever the wording, except for
`account_*` codes, which no subscription would lift.

MaterializeGate enforces the subscriber check before anything is sent: a
free account confirming a creating target fails with SubscriptionRequired
and issues no request at all. It fails open — an unreadable customerStatus
leaves the server as the real gate rather than locking out a subscriber
whose /api/user call happened to fail.

No entry points and no UI: those are the sibling issues. :app depends on
the module only so its Hilt modules join the component.

Tests: 44 new (MockWebServer round-trip per destination asserting the
exact body, all five source kinds, every column type, explicit-null
sourceKey, free-account confirm with zero requests, error-code mapping).
Whole repo green: :app:assembleDebug plus 816 unit tests, 0 failures.

Closes #11
@Adron
Adron merged commit 3190523 into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant