Swasti · mForm V2→V3

Frappe Mobile Control — om/mobile-sync-bulk-fetch deep dive

UPDATE 2026-05-27 — MERGED. om/mobile-sync-bulk-fetch is now merged into develop (tip f7b416e, 2026-05-22) — develop carries the bulk-fetch + offline work. Swasti now tracks develop for both the local bench app and the deploy. Re-pull develop to pick up the merge (and the SDK-side schema rework that addresses the docs__ rebuild issue — see note 07). The deep-dive below was written against the feature branch; the architecture is unchanged, only the branch moved.

Companion to 02-reliance-frappe-context.md (rel-mis used an earlier version) and 07-frappe-mobile-sdk.md (the Flutter SDK that calls these endpoints). Source: https://github.com/dhwani-ris/frappe-mobile-control (branch develop). Local clone: ~/frappe-bench-swasti/apps/mobile_control/.

Working rule (updated): the feature branch is merged, so Swasti tracks develop. Keep main release-only (PR-merge from develop) — don’t push feature work straight to main. Day-to-day mobile-control changes for Swasti come from develop.


1. What it is

mobile_control is a Frappe app (not standalone) that the Frappe Mobile SDK (Flutter) requires on the server. It provides:

  • The mobile-side auth API (login, OTP, refresh tokens, social login) under the mobile_auth.* namespace.
  • The mobile-side sync API (mobile_sync.*) — bulk doc fetch is the new headline.
  • A server-side workspace configuration (Mobile Configuration Single DocType) that tells the SDK which DocTypes to expose on mobile, with grouping, ordering, and an offline-mode toggle.
  • A catch-all attachment-relink hook that fixes a Frappe v16 quirk where mobile-uploaded files don’t auto-attach to child rows.
  • A token rotation system with refresh-token storage.

App identity (hooks.py:1-7):

  • app_name = "mobile_control" · publisher DHWANI RIS · license MIT
  • app_email = "frappeteam@dhwaniris.com"

Verified live on Frappe v16.13 (per attachment_relink commit message).


2. High-level architecture

LayerComponent
DocTypes (4)Mobile Configuration (Single) · Mobile Configuration Form (child Table) · Mobile Workspace Group · Mobile Refresh Token
Whitelisted methods (13)All under override_whitelisted_methods — see §4
Doc events*.on_update + *.on_update_after_submitattachment_relink (fast-exits if no mobile_uuid); DocType / Custom Field / Property Setter .on_update+on_trash → bumps doctype_meta_modifed_at so SDK knows the schema changed
Auth hookauth_hooks = ["mobile_control.auth.validate"] — validates JWT-style access tokens
Override whitelisted methods13 mobile_auth.* + 1 mobile_sync.get_docs_with_children aliases
FixturesShips a Role: Mobile User
Patchesmobile_control/patches/v1_0/set_offline_enabled_default.py (Single-DocType-aware, per fix 2134823)
Apps screen entryadd_to_apps_screen registers the /mobile_control route with has_app_permission check

No scheduled tasks. No permission_query_conditions hook (the app filters per-doc, not per-query). No override_doctype_class. No geography helpers (those layer above this app — rel-mis does it in rf_mis).


3. DocTypes provided

3.1 Mobile Configuration (Single)

mobile_control/mobile_control/doctype/mobile_configuration/mobile_configuration.json

Single — site-wide config the SDK polls.

FieldTypeNotes
enabledCheckMaster switch — when off, login response strips mobile config
offline_enabledCheckNEW (P1 in this branch) — depends_on eval:doc.enabled. SDK reads this, persists to sdk_meta on login. Missing/false → SDK runs as pure-network client. Default 0.
package_nameDataAndroid / iOS package id
minimum_app_versionDataForce-upgrade gate
maintenance_modeCheckHard-block clients
maintenance_messageSmall TextShown when maintenance_mode is on
table_lwisTable → Mobile Configuration FormThe list of DocTypes exposed to mobile

3.2 Mobile Configuration Form (child Table — istable: 1)

FieldTypeNotes
mobile_workspace_itemLink → DocTypeThe DocType to expose
workspace_group_nameLink → Mobile Workspace GroupGrouping for the mobile UI
doctype_iconIconUI icon
orderInt (non_negative)Display order
doctype_meta_modifed_atDatetimeAuto-updated by doc_events when DocType / Custom Field / Property Setter changes — SDK uses this to invalidate cached schemas

3.3 Mobile Workspace Group

mobile_workspace_group/

FieldTypeNotes
group_nameData, unique, autonameThe group label (e.g. “Health”, “Schemes”)

Permissions: System Manager only.

3.4 Mobile Refresh Token

mobile_refresh_token/

FieldTypeNotes
userLink → User, reqdToken owner
token_hashData, unique, read_only, reqdSHA-256 of the refresh token (never stored plaintext)
expires_atDatetime30-day default
revokedCheckSet on logout / rotation
last_usedDatetimeUpdated on each refresh
device_idDataPer-device tokens
user_agentDataAudit trail

hide_toolbar: 1, read_only: 1, in_create: 1. Permissions: System Manager only.


4. Whitelisted endpoints

All exposed via api/v2/method/<alias> (per README). Aliases live in hooks.py:185-199 override_whitelisted_methods.

AliasSource (file:line)HTTPAuthIdempotentPurpose
mobile_auth.loginapi_auth.py:79POSTGuestNo (rotates refresh token)Username/password login
mobile_auth.logoutapi_auth.py:226POSTAuthedYesRevoke all refresh tokens for user
mobile_auth.send_login_otpapi_auth.py:252POSTGuestNo (sends SMS)Send OTP to phone
mobile_auth.verify_login_otpapi_auth.py:280POSTGuestNoVerify OTP, issue tokens
mobile_auth.refresh_tokenapi_auth.py:188POSTGuest (uses refresh token)No (rotates)Exchange refresh for new access; rotates refresh too
mobile_auth.app_statusapi_auth.py:46GETGuestYesApp version + maintenance gate
mobile_auth.configurationapi_auth.py:58GETGuestYesMobile config payload (filtered by perms for non-Guest, NEW)
mobile_auth.permissionsapi_auth.py:323POSTAuthedYesUser’s roles + per-DocType CRUD permissions
mobile_auth.meapi_auth.py:360GETAuthedYesNEW (f813949) — current user details + permissions
mobile_auth.get_translationsapi_auth.py:371GETAuthedYesTranslation dict for one or more languages (lang=hi,en · all=1 for full apps+DB)
mobile_auth.get_social_login_providersapi_auth.py:72GET/POSTGuestYesDiscover enabled social providers
mobile_auth.get_social_authorize_urlapi_auth.py:397GETAuthedYesBuild provider-direct OAuth URL for one-tap social login
mobile_sync.get_docs_with_childrenbulk_fetch.py:73POSTAuthedYesBulk-fetch parent docs with embedded child rows — see §5

Token TTLs: access_token 24h, refresh_token 30d (rotated on every refresh).


5. Bulk-fetch flow — the headline of this branch

Commit: 3bdfef6 feat(mobile_sync): bulk doc fetch + permission-filtered config (Apr 27 2026).

Why: Replaces the SDK’s N+1 pattern of one GET /api/resource/<doctype>/<name> per row. A 1000-row pull goes from ~1001 HTTP calls to ~6.

Endpoint

POST /api/v2/method/mobile_sync.get_docs_with_children

Request

{ "doctype": "Member", "names": ["MEM-0001", "MEM-0002", "MEM-0003"] }
  • names may also arrive JSON-encoded as a string (form-encoded body) — _parse_names (bulk_fetch.py:44) handles both shapes.

Response

[ { "name": "MEM-0001", ..., "<child_table>": [ {...}, {...} ] }, ... ]

Plain list of as_dict() payloads with child rows already embedded. Order is NOT preserved; missing / forbidden / non-existent names are silently dropped — clients must key by name themselves.

Security model (bulk_fetch.py:10-24)

  1. Doctype-level gate up-front via frappe.has_permission(doctype, "read") — single 403 for zero-access users (vs. silent empty list).
  2. Per-doc gate via doc.check_permission("read") — same path as /api/resource/<doctype>/<name>. Honors role perms, User Permissions, permission conditions, and if_owner.
  3. Child doctypes rejected. Frappe’s permission model is parent-anchored; callers must fetch the parent (children come back inside as_dict()).
  4. MAX_BATCH = 200 — hard cap; SDK chunks larger lists client-side. Constant must stay in sync with doctype_service.dart in the SDK (per code comment).
  5. Forbidden names silently dropped — no info leak about whether the name exists.

Companion change in same commit

mobile_auth.configuration (i.e. get_mobile_configuration) now filters table rows by has_permission("read", throw=False) for non-Guest sessions. Authenticated users only see workspace items they can actually open. Guest behaviour unchanged.

What’s NEW vs. the prior version

This is the new endpoint — no prior bulk-fetch existed. The prior version made the SDK N+1 hit /api/resource/....


6. Form / DocType registration for mobile

Concrete steps to expose a Swasti DocType (e.g. Member Profile or Health Screening):

  1. Create the Mobile Workspace Group record (e.g. Health) once, manually in Desk or via fixture.
  2. Open the Mobile Configuration Single in Desk (/app/mobile-configuration).
  3. Toggle enabled. Optionally toggle offline_enabled (only meaningful when enabled is on; default 0).
  4. Set package_name, minimum_app_version, maintenance_mode, maintenance_message as needed.
  5. In the Forms Configuration child table, add one row per DocType:
    • mobile_workspace_item = Health Screening (link to the parent DocType you registered)
    • workspace_group_name = Health
    • doctype_icon = an Icon picker value
    • order = display order (Int)
    • doctype_meta_modifed_at is auto-managed — leave blank
  6. Save.

Effects:

  • The SDK’s next mobile_auth.configuration / mobile_auth.login response includes that row.
  • Whenever the underlying DocType, any of its Custom Fields, or any Property Setter change, the doc_events fire and bump doctype_meta_modifed_at — the SDK uses this as a cache-buster and re-pulls the form definition (the SDK’s metadata path; the om/offline_improvement SDK branch is documented in 07-frappe-mobile-sdk.md).
  • The DocType’s permission rules (Frappe role perms + User Permissions + permission_query_conditions if any app provides them) are honored end-to-end: in the workspace listing (filtered server-side), in mobile_auth.permissions per-CRUD flags, in mobile_sync.get_docs_with_children per-row checks.

No bench command, no JSON file, no manual REST call — registration is data, not code.


7. Permission model

mobile_control/api/helpers/permissions.py:21-50get_user_permissions(user):

for workspace_item in [row.mobile_workspace_item for row in mobile_config]:
    permissions_list.append({
        "doctype": workspace_item,
        "read":   _has_doctype_permission(workspace_item, "read",   user.name),
        "write":  _has_doctype_permission(workspace_item, "write",  user.name),
        "create": _has_doctype_permission(workspace_item, "create", user.name),
        "delete": _has_doctype_permission(workspace_item, "delete", user.name),
        "submit": _has_doctype_permission(workspace_item, "submit", user.name),
        "cancel": _has_doctype_permission(workspace_item, "cancel", user.name),
        "amend":  _has_doctype_permission(workspace_item, "amend",  user.name),
    })

Wraps frappe.has_permission(..., throw=True) and converts PermissionErrorFalse. Returns {"roles": [...], "permissions": [...per-doctype...]}. The SDK uses this to gate UI affordances (don’t render a ”+” button if create=False, etc.).

Cross-reference vs. rel-mis: the deeper geography-aware filter (get_permission_query_conditions walking ancestor/descendant geography in rf_mis/permissions.py) lives in the app on top, not in mobile_control. Swasti’s simpler geography model means the Swasti Frappe app likely doesn’t need a permission_query_conditions at all, or only a thin one — see feedback_swasti_rel_mis_reference.md.


8. Geography handling

This app does not handle geography. No User Program Assignment, no UPA Geography, no geography helpers, no permission_query_conditions. Geography filtering / prefill is the responsibility of the application layer (rel-mis does it in rf_mis; Swasti’s Frappe app will do its own).

For Swasti — per the post-kickoff feedback memory — the model is simpler than rel-mis: lookup is “for the Location of the User” (per 05-pm-design-doc.md §11), no multi-geography expansion. Likely just a User-level location field + standard Frappe role perms; no extra doctypes needed at the mobile_control layer.


9. Audit log / change log

mobile_control does not implement the response audit log. What it does:

  • doc_events on DocType / Custom Field / Property Setter .on_update / .on_trashupdate_doctype_meta_modified → bumps doctype_meta_modifed_at on the matching Mobile Configuration Form row. This is schema-change tracking, not response history.

Response audit (kickoff D3 — replace mForm self-linking with Frappe-native audit log) is implemented at the application layer:

  • rel-mis pattern: Submission Approval Log child Doctype + workflow_utils.log_workflow_action() wired via doc_events on the form Doctypes (per 02-reliance-frappe-context.md).
  • Swasti A16.3: mirrors that, renamed followup_log, hooks on field-set rather than workflow_state. Lives in the Swasti Frappe app, not here.

Frappe also auto-maintains a built-in Version Doctype (the Change Log / track_changes) which the SDK can read natively for any DocType where it’s enabled.


10. Offline conflict resolution

The server-side does not implement merge / version vectors. The contract is:

  • offline_enabled toggle (added in this branch, commit fb82d57) is a switch, not a CRDT. When on, the SDK runs offline-first; when the device reconnects, it submits queued operations through the same submit_form / update_form endpoints (rf_mis/api/sync.py in rel-mis; Swasti will mirror the pattern).
  • Each saved doc carries a mobile_uuid (the SDK’s client-generated identifier — see attachment_relink.py:38), and the parent’s normal Frappe modified timestamp + Frappe’s optimistic locking (docstatus/amended_from/standard modified checks) handle concurrent-edit detection at the application layer.
  • Last-write-wins by default at the server level. Apps can layer richer conflict logic on top of validate() / before_save hooks, but mobile_control does not.

This means the Swasti Frappe app inherits responsibility for any non-trivial conflict resolution. Expected: standard Frappe last-write-wins is fine for Swasti’s data model (one surveyor edits one member at a time; concurrent edits are rare).


Commit: 5902283 feat(attachment_relink): catch-all hook to relink mobile-uploaded files to child rows (May 1 2026). Lives at mobile_control/attachment_relink.py:1-116.

The problem

  • The SDK uploads attachments BEFORE the parent INSERT/UPDATE so the parent payload can carry the resolved file_url.
  • Each upload is sent fully unattached: no dt, no dn. (Frappe v16’s File controller rejects partial-attach where attached_to_doctype is set but attached_to_name is empty — file.py:151.)
  • After the parent saves, Frappe’s stock attach_files_to_document (registered on *.on_update in apps/frappe/frappe/hooks.py:155-166) walks the parent’s Attach / Attach Image fields, finds File rows where all three attached_to_* are NULL and file_url matches, and rewires them. Works for parents.
  • Fails for child rows. In v16, child rows save via raw db_update() (frappe/model/document.py:613-648) — no lifecycle hooks fire, so stock never reaches them.

The fix

relink_mobile_files(doc, method=None) registered on doc_events['*']['on_update'] and ['on_update_after_submit']:

  1. Fast-exit when doc.mobile_uuid is missing — one attribute lookup. Non-mobile saves pay near-zero cost.
  2. For mobile-tagged saves, walk doc.meta.get_table_fields(). Per child row, find an unattached File row by (file_url, attached_to_doctype IS NULL, attached_to_name IS NULL, attached_to_field IS NULL) and rewire the three columns to (child.doctype, child.name, df.fieldname).
  3. Uses frappe.db.set_value (raw UPDATE, no controller hooks) — avoids recursing into our own hook when File is updated.

Live verified on Frappe v16.13: Website Slideshow → Website Slideshow Item.image rewires correctly.

What this means for Swasti

  • Any DocType registered for mobile must include the mobile_uuid field on parent and (if children carry attachments) on the relevant child Doctypes. The SDK assumes this universally.
  • Attach / Attach Image fields on child DocTypes Just Work without further wiring.

No chunked-upload protocol, no virus scan, no max-size config in this app. Frappe core handles the underlying /api/method/upload_file.


12. Server-side sync log

There is no Mobile Sync Log Doctype in this branch. Telemetry uses Frappe’s standard Error Log (via frappe.log_error), which permissions.py:52 writes to on exception.

Hook scaffolding exists for future expansion: before_job / after_job / after_request hooks point at mobile_control.utils.* but the bodies are stubs.


13. Migration / breaking changes vs. develop

Per git log origin/develop..HEAD (5 commits ahead of develop):

CommitEffect on existing sites
3bdfef6 bulk fetch + permission-filtered configBehaviour change in mobile_auth.configuration for non-Guest sessions: now filters out workspace items the user can’t read. Guest unchanged. No DB migration.
2c42f3d ruff format + isortStyle only
5902283 attachment_relink hookNew doc_events['*'] hook runs on every doc save site-wide. Fast-exits in O(1) when mobile_uuid is missing — but every save now does that one attribute lookup. No DB migration.
bbd3285 ruff formatStyle only
fb82d57 offline_enabled Check field on Mobile ConfigurationDB migration via patch: patches/v1_0/set_offline_enabled_default.py sets the column to 0 on the existing Single. Patch is registered in patches.txt.
d47941a README docsDocs only
f813949 mobile_auth.me endpointPure addition, no migration
2134823 patch fix for Single DocType handlingPatch fix — re-runnable

No deprecated endpoints. All prior endpoints continue to work. mobile_auth.me is additive.


14. Install / setup

cd $PATH_TO_YOUR_BENCH
bench get-app https://github.com/dhwani-ris/frappe-mobile-control --branch develop
bench install-app mobile_control
bench --site <site> migrate    # runs the v1_0 patch

Required Frappe: v16.x (verified on v16.13). Required Python: whatever your Frappe v16 bench uses (3.10+ in practice).

After install:

  1. Create a User and assign the Mobile User role (shipped as a fixture).
  2. Open Mobile Configuration Single, toggle enabled, configure forms.
  3. (Optional) Toggle offline_enabled if the SDK build supports offline.
  4. SDK base URL: any host running this Frappe site.

15. Known limitations / open issues

Mined from code comments, commit messages, and the diff:

  • MAX_BATCH = 200 is duplicated between server (bulk_fetch.py:41) and SDK (doctype_service.dart). Drift risk — ensure both move together.
  • Bulk-fetch order is not preserved. Clients must key by name. Don’t rely on positional join.
  • Child DocTypes can’t be bulk-fetched — by design (parent-anchored permissions). Always fetch parent.
  • Single-DocType handling in patches — required a follow-up fix (2134823). Future patches must check frappe.get_meta(...).issingle before column updates.
  • attachment_relink runs on every save site-wide. The fast-exit is one attribute lookup — but it is one extra attribute lookup per save. For high-write workloads this is measurable; in practice negligible.
  • No Mobile Sync Log Doctype. All sync errors go to Frappe Error Log. If Swasti wants per-device sync telemetry (recommended), it’s an application-layer feature.
  • No request-level rate limiting on mobile_sync.get_docs_with_children. Auth endpoints have rate_limit decorators (api_auth.py:12, 16, 17) but bulk fetch does not. Consider adding for production.
  • Social login is provider-direct one-tap — relies on Frappe Social Login Key configuration. Setup work is on the deployer, not this app.

16. How a Swasti Frappe DocType gets registered for mobile

Concrete walkthrough for Health Screening (Swasti’s redesigned Doctype, target shape per 05-pm-design-doc.md §4):

  1. Build the DoctypeHealth Screening parent + Health Screening Follow-up (or whatever child tables you need for the audit log + section 8 re-screening). Add a mobile_uuid Data field on each (parent and any child carrying Attach fields). Indexed, unique on parent. No fetch_from / mandatory — the SDK populates it at save time.
  2. Wire permissions at the Frappe layer (Role perms + User Permissions) per the Swasti hierarchy (05-pm-design-doc.md §10). No permission_query_conditions needed unless a deeper rule is required.
  3. Register the workspace group once: create Mobile Workspace Group: Health.
  4. Add a row to Mobile Configuration → Forms Configuration:
    • mobile_workspace_item = Health Screening
    • workspace_group_name = Health
    • doctype_icon = stethoscope (or whichever Icon)
    • order = 30 (after Member Profile and Scheme)
  5. Compute helpers (BMI / HB / BP / RBS — A21) live in the Swasti Frappe app, not this one. Wire them via validate() / before_save on the Health Screening Doctype.
  6. Audit log (A16.3) — define Health Screening Followup Log child table on the parent, with the rel-mis log_workflow_action pattern (renamed for Swasti). This app does not provide it.
  7. description: "form_<id>" — set the Doctype’s description field to the persistent mForm-V2 cross-reference (form_1010 for Health Screening) so future operators can trace back to the V2 source. Per 04-mform-to-frappe-skill.md §12.
  8. Verify in the SDK — log in on a test device. The new Doctype appears in the workspace under “Health”. CRUD permissions match the user’s Frappe role.

The SDK change is zero. The skill (mform-to-frappe) handles step 1’s four-files-in-parity output (DocType JSON + .py + .js + .dart); steps 2-7 are server-side configuration.


17. Open questions for the app author (omprakash)

  1. Will MAX_BATCH=200 change between SDK and server independently? Suggest hoisting to a Frappe Site Config / settings field so server can shrink without an SDK release. Or embed in mobile_auth.configuration payload so SDK reads at login.
  2. Rate-limit on get_docs_with_children? Currently unbounded except by MAX_BATCH per request. A hostile client can fire MAX_BATCH-sized requests in a tight loop. Suggest @rate_limit like the auth endpoints.
  3. mobile_auth.me vs. mobile_auth.permissions — overlap? The README implies me returns “current user details and permissions” and permissions returns roles + per-doctype CRUD. Are these meant to merge in a future release?
  4. Sync log telemetry — is a Mobile Sync Log Doctype on the roadmap, or are deployments expected to use external APM (Sentry, etc.)?
  5. Schema bump propagationdoctype_meta_modifed_at updates per workspace-item row, but does the SDK pull only the changed forms or refetch the whole config? (Cross-reference with the om/offline_improvement SDK doc — 07-frappe-mobile-sdk.md.)
  6. mobile_uuid discoverability — is there a documented “checklist for adding a new mobile DocType” that calls out mobile_uuid as required, or is it folklore? Worth promoting to a fixture / standard pattern.

18. Cross-reference index

  • Auth response shape including offline_enabled / mobile_form_names / roles / permissions: see ~/Dhwani/swasti-mform-migration/raw/frappe-mobile-control/README.md “Auth response shape” section.
  • rel-mis Frappe app patterns (UPA, geography, audit log): 02-reliance-frappe-context.md.
  • mForm→Frappe conversion skill (4 files in parity, validation _id table): 04-mform-to-frappe-skill.md.
  • PM design authority (workspace groups, role hierarchy, DocType targets): 05-pm-design-doc.md.
  • Feedback memories that govern Swasti decisions: feedback_swasti_rel_mis_reference.md, feedback_swasti_redesign_not_mirror.md.

Repository: https://github.com/dhwani-ris/frappe-mobile-control. Branch: om/mobile-sync-bulk-fetch. Local clone: ~/Dhwani/swasti-mform-migration/raw/frappe-mobile-control/. Will merge to develop soon — re-pull then.


Last updated 2026-05-04