Wrapper
The validate_partial method in all generated model classes had a critical bug where it was not converting Python field names to Firestore aliases. This caused Firestore batch updates to create new fields with Python names instead of updating the existing aliased fields.
@classmethod
def validate_partial(cls, data: dict) -> dict[str, Any]:
return {
k: TypeAdapter(cls.model_fields[k].annotation).validate_python(v)
for k, v in data.items()
if k in cls.model_fields
}
Bug: The method returned the same keys that were passed in.
# Input with Python field names
updates = InviteModel.validate_partial({
'processed': True,
'reminder_sent': True
})
# Output: {'processed': True, 'reminder_sent': True} ❌ WRONG
# Should be: {'INT_P': True, 'INT_R_S': True} ✅ CORRECT
When used with batch.update() (as in invite_db.py):
updates = InviteModel.validate_partial({'processed': True, 'reminder_sent': True})
batch.update(doc_ref, updates)
This would create NEW fields in Firestore:
- ❌ Created:
processed(new field with Python name) - ❌ Created:
reminder_sent(new field with Python name)
Instead of updating the existing aliased fields:
- ✅ Should update:
INT_P(existing field) - ✅ Should update:
INT_R_S(existing field)
@classmethod
def validate_partial(cls, data: "Unpack[InviteDict]") -> dict[str, Any]:
"""
Uses Unpack to give you autocomplete and static warnings
if you pass an invalid key or type in your code.
Returns a dict with Firestore field names (aliases) for use with batch.update().
"""
result = {}
for k, v in data.items():
if k in cls.model_fields:
field_info = cls.model_fields[k]
validated_value = TypeAdapter(field_info.annotation).validate_python(v)
# Use alias if defined, otherwise use field name
output_key = field_info.alias if field_info.alias else k
result[output_key] = validated_value
return result
- Extracts field info:
field_info = cls.model_fields[k] - Uses alias if defined:
output_key = field_info.alias if field_info.alias else k - Returns aliased keys:
result[output_key] = validated_value
# Input with Python field names
updates = InviteModel.validate_partial({
'processed': True,
'reminder_sent': True,
'user_created': False
})
# Output: {'INT_P': True, 'INT_R_S': True, 'INT_U_C': False} ✅ CORRECT
Now when passed to batch.update(), it correctly updates the aliased fields in Firestore!
- File: code_generation/model/templates/model.py.jinja
- Change: Updated
validate_partialmethod template to convert to aliases
All models in functions/flipdare/generated/model/ were regenerated:
archived_model.pychat_comment_model.pychat_model.pycontent_model.pydare_model.pyfriend_model.pygroup_member_model.pygroup_model.pyinvite_model.py✅liked_model.pynotification_model.pypledge_model.pypledge_stats_model.pyuser_model.py
python .\code_generation\codegen_models.py
| Python Field Name | Firestore Alias | Description |
|---|---|---|
version | VERSION | Version number |
processed | INT_P | Processing status |
error_count | INT_E | Error counter |
user_created | INT_U_C | User created flag |
email_sent | INT_E_S | Email sent flag |
friends_created | INT_F_C | Friends created flag |
indexed | INT_I | Indexing status |
reminder_sent | INT_R_S | Reminder sent flag |
# Creating partial updates for Firestore
updates = InviteModel.validate_partial({
'processed': True,
'reminder_sent': True
})
# Updates now contain aliases: {'INT_P': True, 'INT_R_S': True}
batch.update(doc_ref, updates)
# InviteDict uses Python field names
data: InviteDict = {
'from_uid': 'user123',
'from_email': 'user@example.com',
'to_email': 'friend@example.com',
'processed': True
}
# Model creation with Python names still works
invite = InviteModel(**data)
invite = InviteModel(...)
json_data = invite.as_json() # Uses by_alias=True, returns aliased keys
To verify the fix:
Check output of validate_partial:
updates = InviteModel.validate_partial({'processed': True}) assert 'INT_P' in updates # Should have alias assert 'processed' not in updates # Should NOT have Python nameVerify Firestore updates:
- Check that existing
INT_P,INT_R_S, etc. fields are updated - Check that no new
processed,reminder_sentfields are created
- Check that existing
The old commented-out implementation in app_base_model.py DID support aliases and preserved the original key (whether Python name or alias). However, the generated model implementation didn’t follow this pattern and only accepted Python names, returning them as-is without conversion.
All generated models with internal fields using aliases were affected:
InviteModel(INT_P, INT_R_S, INT_U_C, INT_E_S, INT_F_C, INT_I)UserModel(various internal fields)DareModel,GroupModel,FriendModel, etc.
This fix ensures that validate_partial correctly converts Python field names to Firestore aliases, preventing the creation of duplicate fields with Python names in the Firestore database. All generated models have been updated to include this fix.
Refactored trigger data handling to use proper type annotations with PersistedWrapper types, clarifying that all trigger data comes from Firestore databases and includes document IDs.
Before:
TModel = TypeVar("TModel", bound=BaseWrapper)
After:
# TModel is bound to BaseWrapper (the wrapper class, e.g. UserWrapper, DareWrapper)
# All instances returned are PersistedWrapper[TModel] since they come from Firestore with doc_id
TModel = TypeVar("TModel", bound=BaseWrapper)
Key Improvements:
- Added comprehensive docstrings to
TriggerDataandUpdateTriggerDataclasses - Clarified that
TModelis the wrapper class (UserWrapper, DareWrapper, etc.) - Documented that all methods return
PersistedWrapper[TModel]instances - Explained VERSION field tracking for user vs backend updates
Before (INCORRECT):
def tr_user_new(event: NewEventType) -> None:
_new(PersistedWrapper[UserWrapper], AppJobType.TR_USER, event)
def _new(model_class: type[PersistedWrapper], job_type: AppJobType, event: NewEventType) -> None:
get_job_admin().trigger_job(job_type, TriggerData(model_class, job_type.label, event))
After (CORRECT):
def tr_user_new(event: NewEventType) -> None:
_new(UserWrapper, AppJobType.TR_USER, event)
TModel = TypeVar("TModel", bound=BaseWrapper)
def _new(model_class: type[TModel], job_type: AppJobType, event: NewEventType) -> None:
get_job_admin().trigger_job(job_type, TriggerData(model_class, job_type.label, event))
Why This Matters:
PersistedWrapper[UserWrapper]is a generic alias, not an instantiable type- The correct type to pass is the wrapper class itself:
UserWrapper TriggerData._create_model()internally createsPersistedWrapper[TModel]from the wrapper class- Fixed for all trigger types: User, Invite, Friend, Dare, Group, GroupMember, Issue, PaymentIssue, Flag, Pledge
Added comprehensive docstring:
def trigger_job(self, job_type: AppJobType, validator: TriggerData) -> None:
"""
Creates a job in the database from trigger data.
Flow:
1. Firestore trigger fires (e.g., user document created/updated)
2. TriggerData validates and creates PersistedWrapper[TModel] from the event
3. This method stores the model as a dict in the jobs collection
4. AppScheduler later processes these jobs, fetching fresh data from Firestore
Args:
job_type: The type of job (e.g., TR_USER, TR_DARE)
validator: TriggerData containing PersistedWrapper[TModel] from the trigger event
"""
Added comprehensive docstring:
def _process_trigger(self, job_type: AppJobType) -> None:
"""
Process trigger jobs from the database.
Flow:
1. Fetches AppJobModel instances (PersistedWrapper[AppJobModel]) from jobs collection
2. Each job contains obj_id pointing to the actual document
3. Trigger handler (e.g., trigger_user) receives the job
4. Handler fetches fresh PersistedWrapper[TModel] from Firestore using obj_id
5. Handler calls processor with the fresh model data
This ensures handlers always work with current data, not stale trigger snapshots.
"""
┌─────────────────────────────────────────────────────────────────┐
│ 1. Firestore Trigger Fires │
│ Document: user/abc123 created/updated │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. app_triggers.py │
│ _new(UserWrapper, AppJobType.TR_USER, event) │
│ │
│ TModel = UserWrapper (the wrapper class) │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. TriggerData(UserWrapper, "tr_user_new", event) │
│ - Extracts doc_id from event.params["doc_id"] │
│ - Calls UserWrapper.from_dict(data) │
│ - Returns PersistedWrapper[UserWrapper] with doc_id set │
│ │
│ validator.model → PersistedWrapper[UserWrapper] │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. job_admin.trigger_job() │
│ - Calls validator.model.as_dict() │
│ - Stores dict in jobs collection │
│ │
│ Job document: {obj_id: "abc123", model_data: {...}} │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. app_scheduler._process_trigger() │
│ - Fetches job: PersistedWrapper[AppJobModel] │
│ - Gets obj_id = "abc123" │
│ - Calls trigger handler │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 6. user_admin.trigger_user(job: AppJobWrapper) │
│ - Calls user_bridge.get(job.obj_id) │
│ - Gets fresh PersistedWrapper[UserWrapper] from Firestore │
│ - Calls user_processor.process_user(user, is_update=...) │
└────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 7. user_processor.process_user() │
│ - Receives PersistedWrapper[UserWrapper] │
│ - Processes with StepProcessor │
│ - Updates model state fields │
└─────────────────────────────────────────────────────────────────┘
- Represents a wrapper instance that has been persisted to Firestore
- Always has a
doc_idset (never None) - Created from Firestore documents via
Wrapper.from_dict(data) - Used throughout the codebase for database-sourced models
- The base class for all wrappers:
UserWrapper,DareWrapper,GroupWrapper, etc. - These are the types passed to
TriggerData - NOT instances - just the class itself
TModel = TypeVar("TModel", bound=BaseWrapper)
# TModel is the wrapper class (e.g., UserWrapper)
# Methods return PersistedWrapper[TModel] instances
class TriggerData(Generic[TModel]):
def __init__(self, model_class: type[TModel], ...):
# model_class is UserWrapper, DareWrapper, etc.
...
@property
def model(self) -> PersistedWrapper[TModel] | None:
# Returns PersistedWrapper[UserWrapper] if model_class was UserWrapper
return self._instance
The VERSION field (integer) is used to determine the source of Firestore updates:
- Triggered by Cloud Functions
- Triggered by admin operations
- Triggered by scheduled jobs
- UpdateTriggerData.version_changed() returns False
- Triggered by mobile app
- Triggered by web app
- Triggered by direct user actions
- UpdateTriggerData.version_changed() returns True
This allows triggers to differentiate between user-initiated changes and backend processing, preventing infinite trigger loops.
- Type Safety: Correct type annotations prevent runtime errors
- Clarity: Clear documentation explains the data flow
- Maintainability: Future developers understand why PersistedWrapper is used
- Consistency: All triggers follow the same pattern
- Documentation: Comprehensive docstrings explain the architecture
When testing triggers:
# CORRECT: Pass the wrapper class
validator = TriggerData(UserWrapper, "test_trigger", event)
# INCORRECT: Don't pass PersistedWrapper[UserWrapper]
validator = TriggerData(PersistedWrapper[UserWrapper], "test_trigger", event) # ❌
# validator.model returns PersistedWrapper[UserWrapper]
user = validator.model
assert user is not None
assert user.doc_id == "abc123" # doc_id is populated from event params
functions/flipdare/job/trigger_data.py
- Added type comments and comprehensive docstrings
- Clarified TModel type parameter usage
functions/flipdare/app_triggers.py
- Fixed all trigger functions to pass wrapper class instead of PersistedWrapper
- Updated helper functions (_new, _upd, _sub) with correct generic types
- Changed 13 trigger functions (user, invite, friend, dare, group, etc.)
functions/flipdare/job/job_admin.py
- Enhanced trigger_job docstring with data flow explanation
functions/flipdare/admin/core/app_scheduler.py
- Enhanced _process_trigger docstring with processing flow
All files compile without errors:
- ✅ trigger_data.py - No errors
- ✅ app_triggers.py - No errors
- ✅ job_admin.py - No errors
- ✅ app_scheduler.py - No errors
- Consider adding integration tests that verify the full trigger → job → processor flow
- Consider adding type assertions in critical paths to catch type mismatches early
- Consider adding validation that PersistedWrapper instances always have doc_id set