Frappe Mobile Control — om/mobile-sync-bulk-fetch deep dive
UPDATE 2026-05-27 — MERGED.
om/mobile-sync-bulk-fetchis now merged intodevelop(tipf7b416e, 2026-05-22) — develop carries the bulk-fetch + offline work. Swasti now tracksdevelopfor both the local bench app and the deploy. Re-pulldevelopto pick up the merge (and the SDK-side schema rework that addresses thedocs__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) and07-frappe-mobile-sdk.md(the Flutter SDK that calls these endpoints). Source:https://github.com/dhwani-ris/frappe-mobile-control(branchdevelop). Local clone:~/frappe-bench-swasti/apps/mobile_control/.Working rule (updated): the feature branch is merged, so Swasti tracks
develop. Keepmainrelease-only (PR-merge fromdevelop) — don’t push feature work straight tomain. Day-to-day mobile-control changes for Swasti come fromdevelop.
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 ConfigurationSingle 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"· publisherDHWANI RIS· license MITapp_email = "frappeteam@dhwaniris.com"
Verified live on Frappe v16.13 (per attachment_relink commit message).
2. High-level architecture
| Layer | Component |
|---|---|
| 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_submit → attachment_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 hook | auth_hooks = ["mobile_control.auth.validate"] — validates JWT-style access tokens |
| Override whitelisted methods | 13 mobile_auth.* + 1 mobile_sync.get_docs_with_children aliases |
| Fixtures | Ships a Role: Mobile User |
| Patches | mobile_control/patches/v1_0/set_offline_enabled_default.py (Single-DocType-aware, per fix 2134823) |
| Apps screen entry | add_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.
| Field | Type | Notes |
|---|---|---|
enabled | Check | Master switch — when off, login response strips mobile config |
offline_enabled | Check | NEW (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_name | Data | Android / iOS package id |
minimum_app_version | Data | Force-upgrade gate |
maintenance_mode | Check | Hard-block clients |
maintenance_message | Small Text | Shown when maintenance_mode is on |
table_lwis | Table → Mobile Configuration Form | The list of DocTypes exposed to mobile |
3.2 Mobile Configuration Form (child Table — istable: 1)
| Field | Type | Notes |
|---|---|---|
mobile_workspace_item | Link → DocType | The DocType to expose |
workspace_group_name | Link → Mobile Workspace Group | Grouping for the mobile UI |
doctype_icon | Icon | UI icon |
order | Int (non_negative) | Display order |
doctype_meta_modifed_at | Datetime | Auto-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/
| Field | Type | Notes |
|---|---|---|
group_name | Data, unique, autoname | The group label (e.g. “Health”, “Schemes”) |
Permissions: System Manager only.
3.4 Mobile Refresh Token
mobile_refresh_token/
| Field | Type | Notes |
|---|---|---|
user | Link → User, reqd | Token owner |
token_hash | Data, unique, read_only, reqd | SHA-256 of the refresh token (never stored plaintext) |
expires_at | Datetime | 30-day default |
revoked | Check | Set on logout / rotation |
last_used | Datetime | Updated on each refresh |
device_id | Data | Per-device tokens |
user_agent | Data | Audit 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.
| Alias | Source (file:line) | HTTP | Auth | Idempotent | Purpose |
|---|---|---|---|---|---|
mobile_auth.login | api_auth.py:79 | POST | Guest | No (rotates refresh token) | Username/password login |
mobile_auth.logout | api_auth.py:226 | POST | Authed | Yes | Revoke all refresh tokens for user |
mobile_auth.send_login_otp | api_auth.py:252 | POST | Guest | No (sends SMS) | Send OTP to phone |
mobile_auth.verify_login_otp | api_auth.py:280 | POST | Guest | No | Verify OTP, issue tokens |
mobile_auth.refresh_token | api_auth.py:188 | POST | Guest (uses refresh token) | No (rotates) | Exchange refresh for new access; rotates refresh too |
mobile_auth.app_status | api_auth.py:46 | GET | Guest | Yes | App version + maintenance gate |
mobile_auth.configuration | api_auth.py:58 | GET | Guest | Yes | Mobile config payload (filtered by perms for non-Guest, NEW) |
mobile_auth.permissions | api_auth.py:323 | POST | Authed | Yes | User’s roles + per-DocType CRUD permissions |
mobile_auth.me | api_auth.py:360 | GET | Authed | Yes | NEW (f813949) — current user details + permissions |
mobile_auth.get_translations | api_auth.py:371 | GET | Authed | Yes | Translation dict for one or more languages (lang=hi,en · all=1 for full apps+DB) |
mobile_auth.get_social_login_providers | api_auth.py:72 | GET/POST | Guest | Yes | Discover enabled social providers |
mobile_auth.get_social_authorize_url | api_auth.py:397 | GET | Authed | Yes | Build provider-direct OAuth URL for one-tap social login |
mobile_sync.get_docs_with_children | bulk_fetch.py:73 | POST | Authed | Yes | Bulk-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"] }
namesmay 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)
- Doctype-level gate up-front via
frappe.has_permission(doctype, "read")— single 403 for zero-access users (vs. silent empty list). - Per-doc gate via
doc.check_permission("read")— same path as/api/resource/<doctype>/<name>. Honors role perms, User Permissions, permission conditions, andif_owner. - Child doctypes rejected. Frappe’s permission model is parent-anchored; callers must fetch the parent (children come back inside
as_dict()). MAX_BATCH = 200— hard cap; SDK chunks larger lists client-side. Constant must stay in sync withdoctype_service.dartin the SDK (per code comment).- 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):
- Create the Mobile Workspace Group record (e.g.
Health) once, manually in Desk or via fixture. - Open the Mobile Configuration Single in Desk (
/app/mobile-configuration). - Toggle
enabled. Optionally toggleoffline_enabled(only meaningful whenenabledis on; default 0). - Set
package_name,minimum_app_version,maintenance_mode,maintenance_messageas needed. - 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=Healthdoctype_icon= an Icon picker valueorder= display order (Int)doctype_meta_modifed_atis auto-managed — leave blank
- Save.
Effects:
- The SDK’s next
mobile_auth.configuration/mobile_auth.loginresponse 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; theom/offline_improvementSDK branch is documented in07-frappe-mobile-sdk.md). - The DocType’s permission rules (Frappe role perms + User Permissions +
permission_query_conditionsif any app provides them) are honored end-to-end: in the workspace listing (filtered server-side), inmobile_auth.permissionsper-CRUD flags, inmobile_sync.get_docs_with_childrenper-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-50 — get_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 PermissionError → False. 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_eventsonDocType/Custom Field/Property Setter.on_update/.on_trash→update_doctype_meta_modified→ bumpsdoctype_meta_modifed_aton the matchingMobile Configuration Formrow. 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 Logchild Doctype +workflow_utils.log_workflow_action()wired viadoc_eventson the form Doctypes (per02-reliance-frappe-context.md). - Swasti A16.3: mirrors that, renamed
followup_log, hooks on field-set rather thanworkflow_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_enabledtoggle (added in this branch, commitfb82d57) is a switch, not a CRDT. When on, the SDK runs offline-first; when the device reconnects, it submits queued operations through the samesubmit_form/update_formendpoints (rf_mis/api/sync.pyin rel-mis; Swasti will mirror the pattern).- Each saved doc carries a
mobile_uuid(the SDK’s client-generated identifier — seeattachment_relink.py:38), and the parent’s normal Frappemodifiedtimestamp + Frappe’s optimistic locking (docstatus/amended_from/standardmodifiedchecks) 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_savehooks, butmobile_controldoes 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).
11. File upload + the attachment_relink quirk
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, nodn. (Frappe v16’s File controller rejects partial-attach whereattached_to_doctypeis set butattached_to_nameis empty —file.py:151.) - After the parent saves, Frappe’s stock
attach_files_to_document(registered on*.on_updateinapps/frappe/frappe/hooks.py:155-166) walks the parent’s Attach / Attach Image fields, finds File rows where all threeattached_to_*are NULL andfile_urlmatches, 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']:
- Fast-exit when
doc.mobile_uuidis missing — one attribute lookup. Non-mobile saves pay near-zero cost. - 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). - 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_uuidfield on parent and (if children carry attachments) on the relevant child Doctypes. The SDK assumes this universally. Attach/Attach Imagefields 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):
| Commit | Effect on existing sites |
|---|---|
3bdfef6 bulk fetch + permission-filtered config | Behaviour 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 + isort | Style only |
5902283 attachment_relink hook | New 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 format | Style only |
fb82d57 offline_enabled Check field on Mobile Configuration | DB 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 docs | Docs only |
f813949 mobile_auth.me endpoint | Pure addition, no migration |
2134823 patch fix for Single DocType handling | Patch 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:
- Create a
Userand assign theMobile Userrole (shipped as a fixture). - Open Mobile Configuration Single, toggle
enabled, configure forms. - (Optional) Toggle
offline_enabledif the SDK build supports offline. - 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 = 200is 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 checkfrappe.get_meta(...).issinglebefore column updates. attachment_relinkruns 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 haverate_limitdecorators (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):
- Build the Doctype —
Health Screeningparent +Health Screening Follow-up(or whatever child tables you need for the audit log + section 8 re-screening). Add amobile_uuidData 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. - Wire permissions at the Frappe layer (Role perms + User Permissions) per the Swasti hierarchy (
05-pm-design-doc.md §10). Nopermission_query_conditionsneeded unless a deeper rule is required. - Register the workspace group once: create
Mobile Workspace Group: Health. - Add a row to
Mobile Configuration → Forms Configuration:mobile_workspace_item = Health Screeningworkspace_group_name = Healthdoctype_icon = stethoscope(or whichever Icon)order = 30(after Member Profile and Scheme)
- Compute helpers (BMI / HB / BP / RBS — A21) live in the Swasti Frappe app, not this one. Wire them via
validate()/before_saveon the Health Screening Doctype. - Audit log (A16.3) — define
Health Screening Followup Logchild table on the parent, with the rel-mislog_workflow_actionpattern (renamed for Swasti). This app does not provide it. description: "form_<id>"— set the Doctype’sdescriptionfield to the persistent mForm-V2 cross-reference (form_1010for Health Screening) so future operators can trace back to the V2 source. Per04-mform-to-frappe-skill.md §12.- 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)
- Will
MAX_BATCH=200change between SDK and server independently? Suggest hoisting to a Frappe Site Config / settings field so server can shrink without an SDK release. Or embed inmobile_auth.configurationpayload so SDK reads at login. - Rate-limit on
get_docs_with_children? Currently unbounded except byMAX_BATCHper request. A hostile client can fire MAX_BATCH-sized requests in a tight loop. Suggest@rate_limitlike the auth endpoints. mobile_auth.mevs.mobile_auth.permissions— overlap? The README impliesmereturns “current user details and permissions” andpermissionsreturns roles + per-doctype CRUD. Are these meant to merge in a future release?- Sync log telemetry — is a
Mobile Sync LogDoctype on the roadmap, or are deployments expected to use external APM (Sentry, etc.)? - Schema bump propagation —
doctype_meta_modifed_atupdates per workspace-item row, but does the SDK pull only the changed forms or refetch the whole config? (Cross-reference with theom/offline_improvementSDK doc —07-frappe-mobile-sdk.md.) mobile_uuiddiscoverability — is there a documented “checklist for adding a new mobile DocType” that calls outmobile_uuidas 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
_idtable):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.