0.1.94 — GitHub Sync static-asset bundling (push + restore round-trip)

Closes the static-asset gap left open in 0.1.93. The data-sync feature is now genuinely server-loss-survivable for accounts whose total asset size fits inside the per-push budget — a user can push with assets, clone the resulting repo, and restore the full account (text + binary) onto a fresh server.

Landed across three scoped commits on main:

  • 406fd2e — push side: opt-in inline of avatar / cover / attachment bytes with size guards + 3 new tests.
  • d719c4c — restore CLI --include-assets flag + frontend toggle.
  • (this commit) — bookkeeping: VERSION + round log + TODO + docs.

Push side

  • creators/services/github_sync.py:
    • Module caps ASSET_FILE_MAX_BYTES = 50 MB and ASSET_TOTAL_MAX_BYTES = 200 MB. Per-file cap stays under GitHub's 100 MB Contents API blob limit; total cap keeps a single push from blowing the user's repo size budget.
    • _read_field_bytes and _ext_from_name helpers. Field reads are lossy by design — a missing avatar shouldn't kill a push.
    • _asset_files(creator, *, skipped) writes:
      • assets/avatar.<ext> (single profile picture)
      • assets/notes/<note-uuid>/cover.<ext>
      • assets/notes/<note-uuid>/attachments/<attachment-uuid>.<ext> Files past either cap are recorded in skipped; the parent record's URL reference in the JSON sidecar is unchanged.
    • materialize(creator, *, include_assets=False) gains the flag. The manifest now carries include_assets + skipped_assets so the restore CLI knows what to look for.
    • push_user_data(creator, *, include_assets=False) threads the flag through; log line records it for ops audit.
  • creators/api.py GithubSyncPushApiView accepts include_assets from query string or JSON body (truthy strings = 1/true/yes/on); response echoes the flag.
  • 3 new tests in creators.tests.GithubSyncTests:
    • test_materialize_skips_assets_by_default — default export contains zero assets/ paths.
    • test_materialize_include_assets_inlines_avatar_and_cover — flag flips manifest + writes both avatar and cover bytes.
    • test_materialize_skips_assets_over_per_file_cap — patched cap proves the size guard records the skip in manifest.skipped_assets. All 13 GithubSyncTests pass.

Restore side

  • backend/scripts/github_sync_restore.py:
    • New --include-assets flag.
    • RestoreClient.upload(method, path, *, field, filename, content, extra_fields) builds multipart/form-data with stdlib only so the script stays zero-dep.
    • _restore_notes now populates a uuid_to_id map from each POST /notes/ response. The asset phase keys off it to target the right note's cover / attachment endpoints.
    • _restore_assets walks assets/:
      • PATCH /api/v1/settings/ with multipart avatar.
      • POST /api/v1/notes/<id>/cover/ with multipart cover.
      • POST /api/v1/notes/<id>/attachments/ with multipart file. Notes whose UUID wasn't restored in this run (already on server, never appeared) are tallied as skipped.
    • Summary JSON gains an assets: {avatar, cover, attachment, skipped} block when the flag is set.

Frontend

  • notechondria_shared/lib/src/components/mcp_skill_section.dart GithubSyncExperimentalCard adds an "Include assets" SwitchListTile in the connected state. The flag is forwarded to onPushNow as includeAssets.
  • onPushNow callback signature is now Future<Map<String, dynamic>> Function({bool includeAssets}).
  • editor / portal / planner client interfaces + impls update githubSyncPush(token, {bool includeAssets = false}) and POST {include_assets: <bool>} in the body.
  • Per-app app_shell (and editor's build_helpers.dart) bind the new callback shape.

Operator notes

  • Once 0.1.94 is deployed, users see the new "Include assets" toggle automatically. No new env vars; the existing GITHUB_DATA_SYNC_APP_* block from 0.1.90 is sufficient.
  • Asset reads pull from whatever DEFAULT_FILE_STORAGE resolves to (local filesystem in dev, S3-compatible storage in prod via django-storages). The Cloudflare R2 path is already covered by the existing boto3 setup — no extra wiring.
  • Per-push caps are intentionally conservative. If your power users need higher limits, raise ASSET_FILE_MAX_BYTES / ASSET_TOTAL_MAX_BYTES in backend/creators/services/github_sync.py and rebuild; both are module-level constants for easy patching.

Verification

  • python manage.py test creators.tests.GithubSyncTests — 13/13 pass under settings_test.
  • flutter analyze — clean across editor_app, portal_app, planner_app. No new warnings; pre-existing unused-* lints unchanged.
  • Restore CLI smoke: synthetic fixture exercises avatar + cover + attachment in the assets tree; dry-run prints the multipart PATCH /settings/ for the avatar and tallies cover/attachment as skipped (dry-run can't capture the note id assigned by POST).

Carryover

  • Push-side conflict resolution. The Contents API PUTs we use overwrite the remote blob. A user editing on two devices between syncs can lose changes. Next round: fetch existing blob, diff against the materialized payload, and surface a "remote changed" warning before overwriting.
  • Asset rotation / pruning. Pushing with assets repeatedly accumulates orphan asset files for notes that have been deleted client-side but where the GitHub commit history still references the old path. A --prune-orphans mode on the push pipeline can walk the Trees API and delete unreferenced assets/notes/<uuid>/ subtrees in the same commit.