Stop hand-writing the 9-file fan-out for every new field. firepack generates typed Dart models, Riverpod providers, repositories, security rules, indexes, storage paths, and a Mermaid graph — deterministically, on every save.
One firepack.yaml
declares your collections, fields, indexes, queries, and rules.
Click any tab to see what gets generated.
firepack: 1
project: blog
collections:
posts:
tenant: organizationId
fields:
id: { type: string, primaryKey: true }
title: { type: string, required: true }
status: { type: "enum[PostStatus]", default: draft }
createdAt: { type: dateTime, required: true,
serverDefault: now }
organizationId: { type: "ref[organizations.id]",
required: true }
indexes:
- fields: [organizationId, status, createdAt:desc]
queries:
watchByOrg:
where: [tenant]
orderBy: createdAt:desc
limit: 50
// GENERATED by firepack — do not edit.
import 'enums.dart';
class Post {
final String id;
final String organizationId;
final String title;
final PostStatus status;
final DateTime createdAt;
const Post({
required this.id,
required this.organizationId,
required this.title,
this.status = PostStatus.draft,
required this.createdAt,
});
Post copyWith({String? id, String? title, PostStatus? status, ...}) =>
Post(id: id ?? this.id, ...);
// Pure JSON — for REST APIs, hashing, snapshot tests.
Map<String, dynamic> toJson() => {
'id': id, 'title': title, 'status': status.toJson(),
'createdAt': createdAt.toIso8601String(), ...
};
factory Post.fromJson(Map<String, dynamic> json) => ...;
// Firestore-native — DateTime stays DateTime,
// cloud_firestore writes Timestamp.
Map<String, dynamic> toFirestore() => {
'id': id, 'title': title, 'status': status.toJson(),
'createdAt': createdAt, ...
};
factory Post.fromFirestore(Map<String, dynamic> data) => Post(
createdAt: _toDateTime(data['createdAt']), // duck-typed
...
);
@override bool operator ==(Object other) => ... // value equality
@override int get hashCode => Object.hash(...);
}
// GENERATED by firepack — do not edit.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../firestore_provider.dart';
import '../paths.dart';
import '../tenant_query.dart';
import '../models/post.dart';
class PostRepository {
final FirebaseFirestore _firestore;
PostRepository(this._firestore);
Stream<List<Post>> watchByOrg(String orgId) =>
_firestore.collection(FirestorePaths.posts)
.scopedToOrg(orgId)
.orderBy('createdAt', descending: true)
.limit(50)
.snapshots()
.map((s) => s.docs.map(_fromDoc).toList());
/// `merge: true` preserves server-only fields the model doesn't carry.
/// `serverDefault: now` injects FieldValue.serverTimestamp() (only on full set).
Future<void> add(Post doc, {bool merge = false}) {
final data = doc.toFirestore();
if (!merge) data['createdAt'] = FieldValue.serverTimestamp();
return _firestore.collection(FirestorePaths.posts)
.doc(doc.id).set(data, SetOptions(merge: merge));
}
Future<void> updateById(String id, Map<String, dynamic> fields) =>
_firestore.collection(FirestorePaths.posts).doc(id).update(fields);
Future<void> deleteById(String id) =>
_firestore.collection(FirestorePaths.posts).doc(id).delete();
}
// Single DI seam — override firestoreProvider once, every repo follows.
final postRepositoryProvider = Provider<PostRepository>(
(ref) => PostRepository(ref.watch(firestoreProvider)),
);
final postWatchByOrgProvider = StreamProvider.family<List<Post>, String>(
(ref, orgId) => ref.watch(postRepositoryProvider).watchByOrg(orgId),
);
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Reusable helpers — emitted from the spec, one read per request.
function isSignedIn() { return request.auth != null; }
function userDoc() { return get(/databases/$(database)
/documents/users/$(request.auth.uid)).data; }
function getUserOrgId() { return userDoc().organizationId; }
function isAdmin() { return userDoc().role == 'admin'; }
function isSupervisorOrAdmin() {
let role = userDoc().role;
return role == 'admin' || role == 'supervisor';
}
match /posts/{id} {
allow read: if isSignedIn() &&
(resource.data.organizationId == getUserOrgId() || isAdmin());
allow create: if isSignedIn() && isSupervisorOrAdmin();
allow update: if isSupervisorOrAdmin();
allow delete: if false;
}
// Secure everything else by default
match /{document=**} { allow read, write: if false; }
}
}
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "organizationId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
And also: paths.dart ·
firestore_provider.dart ·
storage_paths.dart ·
DATA_MODEL.md
— see the
full feature grid.
Every output is deterministic — two runs on the same spec produce byte-identical files. Snapshot tests catch any drift.
Immutable, value-equal, copyWith,
both pure-JSON and Firestore-native (de)serialisation.
Handles Timestamp ⇄ DateTime
via duck-typing — models stay framework-agnostic.
Typed Stream<List<X>>
per spec query. add(merge:),
updateById,
deleteById,
watchById.
StreamProvider.family per
query. Single firestoreProvider
DI seam — override once, every repo follows.
Per-collection read/create/update/delete with reusable helpers
(isSignedIn,
tenantSelf, …).
Deny-all default for unmatched paths.
firestore.indexes.json sorted,
deterministic, dedup'd. Diffs cleanly in PRs — no more "but it
worked locally" because the deploy missed an index.
Typed methods per declared bucket. Drift between upload code, read code, and rules becomes a compile error, not a 3am page.
ER diagram with FKs, nested types, storage cross-system edges. Renders inline on GitHub — your data architecture stays documented automatically.
firepack diff --old --new
renders a Markdown report — added / removed / changed
collections, fields, indexes. Drop-in for PR comments.
firepack watch regenerates
every configured target on every spec save. Pure Dart watcher —
no build_runner, no codegen
DAG, no part-files.
firepack stays out of your way. No build_runner,
no part-files, no codegen DAG. One spec, one watcher, one
regenerated tree.
Add a field to firepack.yaml.
firepack watch notices instantly.
Models, repos, rules, indexes — all rewritten.
Flutter hot-reloads against the new shape. Done.
Pre-pub-dev — install from a local clone.
# Clone + install (path-source, picks up git pull automatically)
git clone https://github.com/moinsen-dev/firepack && cd firepack
dart pub global activate --source path .
export PATH="$PATH:$HOME/.pub-cache/bin" # add to ~/.zshrc
# Try the bundled example app
just example-regen # regenerate the whole data layer
just example-emulator-up # boot Firebase Emulator (other terminal)
just example-run # run Flutter app against the emulator
Need details? The full quick-start in the README walks through the example app's CRUD demo end-to-end.
firepack is built bootstrap-style — every feature lands when a real consumer hits a real pain. If it saved you boilerplate, star the repo so others can find it.
Star firepack on GitHub