Package Document
Overview
A pattern for macOS document-based apps that use directory bundle packages (rendered as single files in Finder) containing SQLite databases. The document is a folder with a custom UTType conforming to com.apple.package, registered with a file extension (e.g., .catnip-proj, .catnip-workspace). Inside the package, one or more SQLite database files store all persistent state. The pattern supports schema versioning via PRAGMA user_version, format migration from legacy JSON files to SQLite, atomic writes through temporary database creation and FileWrapper packaging, and auto-save via SwiftUI's ReferenceFileDocument protocol. Each document type (project, workspace) follows the same structural pattern with its own UTType, file extension, database filename, and schema.
Terminology
| Term | Definition |
|---|---|
| Package document | A directory bundle that macOS presents as a single file in Finder, identified by a custom UTType conforming to com.apple.package |
| UTType | A Uniform Type Identifier declared in Info.plist that maps a file extension to a content type and conformance hierarchy |
| ReferenceFileDocument | A SwiftUI protocol for reference-type documents that triggers auto-save when the document's objectWillChange publisher fires |
| FileWrapper | An Apple framework class representing a file, directory, or symbolic link in memory; used to read from and write to package directories |
| Schema version | An integer stored in SQLite's PRAGMA user_version that identifies the database schema revision |
| Format migration | The process of reading a legacy format (e.g., JSON) and converting it to the current SQLite-based format on first save |
| Atomic write | Writing all data to a temporary SQLite file, reading it back as bytes, and wrapping it in a FileWrapper so the system can perform an atomic directory replacement |
| Key-value settings | A table of string key-value pairs used to store typed settings (booleans as "true"/"false", numbers as string representations) |
| Document scene | A SwiftUI DocumentGroup scene that manages the open/save/close lifecycle for a document type |
Architecture
┌─────────────────────────────────────────────────┐
│ DocumentGroup(newDocument:) │
│ ┌─────────────────────────────────────────────┐ │
│ │ ReferenceFileDocument │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ @Published var model │──objectWillChange│ │
│ │ └───────────┬───────────┘ → auto-save │ │
│ │ │ │ │
│ │ ┌───────────▼───────────┐ │ │
│ │ │ fileWrapper(...) │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ Temp SQLite DB │ │ │ │
│ │ │ │ → Insert data │ │ │ │
│ │ │ │ → Read bytes │ │ │ │
│ │ │ │ → FileWrapper │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Package on disk:
┌──────────────────────────┐
│ MyDocument.catnip-proj/ │ ← Finder shows as single file
│ ├── project.db │ ← SQLite database
│ └── (legacy: data.json) │ ← Removed after migration
└──────────────────────────┘
UTType Registration
Info.plist exported type declaration
Each document type requires an exported UTType entry in Info.plist:
UTExportedTypeDeclarations:
- UTTypeIdentifier: com.example.catnip-project
UTTypeDescription: Catnip Project
UTTypeConformsTo: [com.apple.package]
UTTypeTagSpecification:
public.filename-extension: [catnip-proj]
Swift UTType extension
extension UTType {
static let catnipProject = UTType(exportedAs: "com.example.catnip-project")
static let catnipWorkspace = UTType(exportedAs: "com.example.catnip-workspace")
}
Behavioral Requirements
UTType registration
- package-uttype-declaration: Each document type MUST declare a custom UTType conforming to
com.apple.packagein the app's Info.plist as an exported type. - unique-file-extension: Each UTType MUST specify a unique file extension in
UTTypeTagSpecificationunderpublic.filename-extension. - swift-uttype-property: The UTType MUST be declared as a Swift
UTTypestatic property viaUTType(exportedAs:)for use in document and file panel APIs.
Document protocol conformance
- reference-file-document: Each document class MUST conform to
ReferenceFileDocument(SwiftUI) and declare itsreadableContentTypesandwritableContentTypesas the corresponding custom UTType. - published-model-property: The document MUST expose a
@Published var modelproperty whose changes triggerobjectWillChange, enabling SwiftUI auto-save. - filewrapper-read-write: The document MUST implement
init(configuration:)to read from aFileWrapperandfileWrapper(snapshot:configuration:)to write to aFileWrapper.
SQLite database schema
- journal-mode-off: The SQLite database MUST use
PRAGMA journal_mode = OFFsince the database resides inside a package and is not a standalone file. - pragma-user-version: The SQLite database MUST use
PRAGMA user_version = Nto track the schema version, where N is an integer incremented with each schema change. - metadata-table: The database MUST contain a
metadatatable with columns for document name (TEXT), schema version (INTEGER), and created date (TEXTin ISO 8601 format). - settings-table: The database MUST contain a
settingstable with columnskey(TEXT PRIMARY KEY) andvalue(TEXT) for key-value pair storage. - boolean-string-storage: Boolean settings MUST be stored as the string values
"true"or"false". - numeric-string-storage: Numeric settings MUST be stored as their string representations (e.g.,
"42","3.14"). - domain-specific-tables: Domain-specific data tables MUST be defined per document type (e.g., sessions table for projects, file references for workspaces).
Read process
- check-sqlite-first: On read, the document MUST first check the package
FileWrapperfor the expected SQLite database file (e.g.,project.db). - fallback-legacy-json: If the SQLite database file is not found, the document MUST check for a legacy JSON file (e.g.,
data.json) for backward compatibility. - deserialize-legacy-json: If a legacy JSON file is found, the document MUST deserialize it and populate the model from JSON data. The next save will write SQLite format.
- empty-package-defaults: If neither SQLite nor legacy JSON files are found in the package, the document MUST treat it as a new empty document with default values.
- log-format-version: On successful read, the document MUST log the format version (SQLite schema version or "legacy JSON") at
infolevel.
Write process
- temp-sqlite-write: On write, the document MUST create a temporary SQLite database file at a unique path (UUID-based filename in the temporary directory).
- parameterized-queries: The document MUST insert all model data into the temporary database using parameterized queries.
- read-temp-bytes: After writing all data, the document MUST read the temporary database file contents as raw bytes (
Data). - wrap-database-filewrapper: The document MUST wrap the database bytes in a
FileWrapper(regularFileWithContents:)with thepreferredFilenameset to the database filename (e.g.,project.db). - directory-filewrapper: The document MUST return a
FileWrapper(directoryWithFileWrappers:)containing the database file wrapper, forming the package directory. - cleanup-temp-database: The temporary database file MUST be deleted after its bytes have been read (cleanup in a
deferblock or equivalent).
Migration-safe Codable
- migration-safe-codable: Model types that are deserialized from legacy JSON MUST implement custom
init(from decoder: Decoder)with per-fieldtry/catch, falling back to default values for any field that fails to decode. - model-version-field: Each model type MUST include a
versionfield (integer or string) for schema identification in both JSON and SQLite representations. - backward-compatible-schema: Adding new settings fields to the model MUST NOT break deserialization of documents created with older versions of the schema.
SQLite helper utilities
- temp-database-url-helper: The codebase MUST provide a
tempDatabaseURL()helper that returns a URL in the temporary directory with a UUID-based filename and.dbextension. - exec-with-bindings: The codebase MUST provide an
exec()function that executes a SQL statement with parameterized bindings (supporting at minimum.text(String),.int(Int), and.nullbinding types). - query-functions: The codebase MUST provide
queryRow()andqueryAll()functions for reading single and multiple rows from the database. - last-insert-row-id: The codebase MUST provide a
lastInsertRowID()function to retrieve the row ID of the last inserted row. - sqlite-error-type: SQLite errors MUST be represented as a dedicated error type with cases for:
cannotOpen,execFailed,missingData, andinvalidDate.
Document scenes
- document-group-scene: The app MUST declare a
DocumentGroup(newDocument:)scene for each document type, associating it with the correctReferenceFileDocumentsubclass. - non-document-window-group: Non-document windows (e.g., settings, welcome screen) MUST use
WindowGroupscenes, notDocumentGroup. - custom-menu-save-panel: Custom menu commands for creating new documents MUST use
NSSavePanelto select the save location and then programmatically create the document.
Lifecycle
- autosave-via-published: Auto-save MUST be triggered automatically by the
@Publishedmodel property'sobjectWillChangepublisher. No manual save action is required from the user. - save-open-urls-on-quit: On application quit, the document system MUST save the URLs of all currently open documents for session restoration.
- restore-urls-on-launch: On application launch, the document system MUST attempt to reopen previously saved document URLs, logging any that fail to open.
Appearance
Not applicable — this recipe defines a storage and persistence pattern, not a visual component.
States
| State | Behavior |
|---|---|
| New document | Empty model with default values; first save creates the package directory with a fresh SQLite database |
| Existing SQLite document | Read from SQLite database in the package; schema version checked against current version |
| Legacy JSON document | JSON file detected in the package; model populated from JSON; next save migrates to SQLite format |
| Corrupt database | SQLite open or query fails; document reports an error to the user and does not load |
| Missing database file | Neither SQLite nor JSON found in the package directory; treated as new empty document |
| Schema version mismatch (older) | Database user_version is lower than current; migration logic upgrades the schema on next save |
| Schema version mismatch (newer) | Database user_version is higher than current app version; document reports a version error and refuses to load |
| Auto-save in progress | Model property changed; system serializes to temporary SQLite, wraps in FileWrapper, and writes to package |
| Session restoration | App launches; previously open document URLs are reopened; any that fail are logged and skipped |
Accessibility
Not applicable — this recipe defines a storage and persistence pattern with no direct user interface. Document open/save/error dialogs inherit platform-standard accessibility from NSSavePanel, NSOpenPanel, and SwiftUI alert presentations.
Conformance Test Vectors
| ID | Requirements | Input | Expected |
|---|---|---|---|
| pd-001 | package-uttype-declaration, unique-file-extension, swift-uttype-property | Register UTType for .catnip-proj conforming to com.apple.package |
Finder displays the package directory as a single file icon with the correct extension |
| pd-002 | reference-file-document, published-model-property | Create a new ProjectDocument and modify the model property |
objectWillChange fires; auto-save triggers |
| pd-003 | filewrapper-read-write | Call fileWrapper(snapshot:configuration:) on a document |
Returns a FileWrapper of kind directory containing project.db |
| pd-004 | journal-mode-off | Open the SQLite database inside a saved package | PRAGMA journal_mode returns off |
| pd-005 | pragma-user-version, metadata-table | Open the SQLite database and query PRAGMA user_version and SELECT * FROM metadata |
user_version matches expected schema version; metadata row contains name, version, created_date |
| pd-006 | settings-table, boolean-string-storage, numeric-string-storage | Insert boolean setting autoSave = true and numeric setting fontSize = 14 |
Settings table contains ("autoSave", "true") and ("fontSize", "14") |
| pd-007 | check-sqlite-first | Open a package containing project.db |
Document reads from SQLite successfully |
| pd-008 | fallback-legacy-json, deserialize-legacy-json | Open a package containing data.json but no project.db |
Document reads from JSON; model is populated correctly |
| pd-009 | deserialize-legacy-json | Open a legacy JSON package, modify model, trigger save | Saved package contains project.db (SQLite); legacy JSON format replaced |
| pd-010 | empty-package-defaults | Open an empty package directory (no project.db, no data.json) |
Document initializes with default values |
| pd-011 | log-format-version | Open a SQLite document with schema version 3 | Log entry: info level, includes "schema version 3" |
| pd-012 | temp-sqlite-write, parameterized-queries, read-temp-bytes | Trigger a save on a document with model data | Temporary SQLite file is created, data is inserted with parameterized queries, bytes are read |
| pd-013 | wrap-database-filewrapper, directory-filewrapper | Inspect the FileWrapper returned from fileWrapper(...) |
Directory FileWrapper with one child whose preferredFilename is project.db |
| pd-014 | cleanup-temp-database | Trigger a save and inspect the temporary directory afterward | No leftover temporary .db files remain |
| pd-015 | migration-safe-codable, backward-compatible-schema | Deserialize a legacy JSON document that is missing a field added in a newer schema version | Missing field falls back to its default value; no crash or error |
| pd-016 | migration-safe-codable, backward-compatible-schema | Deserialize a legacy JSON document that contains an unknown extra field | Extra field is ignored; known fields are populated correctly |
| pd-017 | model-version-field | Inspect the model after reading from either JSON or SQLite | Model's version field is populated and matches the source's schema version |
| pd-018 | temp-database-url-helper | Call tempDatabaseURL() twice |
Both URLs are in the temp directory, have .db extension, and are unique (different UUIDs) |
| pd-019 | exec-with-bindings | Call exec("INSERT INTO settings (key, value) VALUES (?, ?)", [.text("k"), .text("v")]) |
Row is inserted; no SQL injection possible with parameterized bindings |
| pd-020 | query-functions | Call queryRow("SELECT value FROM settings WHERE key = ?", [.text("k")]) |
Returns single row with value "v" |
| pd-021 | last-insert-row-id | Insert a row and call lastInsertRowID() |
Returns the integer row ID of the just-inserted row |
| pd-022 | sqlite-error-type | Attempt to open a non-existent database path | Throws error of type .cannotOpen |
| pd-023 | sqlite-error-type | Execute invalid SQL | Throws error of type .execFailed |
| pd-024 | document-group-scene | Launch the app | DocumentGroup scenes are registered for each document type; File > Open shows the correct file type filters |
| pd-025 | autosave-via-published | Modify the model's @Published property |
Auto-save fires without any user action |
| pd-026 | save-open-urls-on-quit | Open two documents, quit the app | Both document URLs are saved for session restoration |
| pd-027 | restore-urls-on-launch | Launch the app after quitting with two documents open | Both documents reopen; if one URL is invalid, the valid one still opens and the failure is logged |
Edge Cases
- Corrupt SQLite database: If
sqlite3_opensucceeds but queries fail (e.g., malformed schema, incomplete write), the document MUST surface a user-facing error describing the corruption and MUST NOT overwrite the corrupt file. The user should be offered the option to create a new document or attempt manual recovery. - Missing files in package: If the package directory exists but contains neither the expected SQLite database nor a legacy JSON file, the document treats this as a new empty document (empty-package-defaults). If the package directory itself is missing or inaccessible, the system reports a file-not-found error.
- Format migration (JSON to SQLite): When a legacy JSON document is opened, the model is populated from JSON. On the next save, the write process creates a SQLite database. The legacy JSON file is not explicitly deleted from the package — the new
FileWrapper(directoryWithFileWrappers:)simply omits it, and the atomic directory replacement removes it. - Concurrent access: If two processes or two app instances attempt to open the same package document simultaneously, behavior is undefined. The pattern relies on macOS file coordination (
NSFileCoordinator) when available, but does not implement custom locking. Documents opened viaDocumentGroupbenefit from the system's built-in file coordination. - Disk full during write: If the temporary SQLite file cannot be fully written due to insufficient disk space, the
exec()call will fail. The document MUST catch this error and surface it to the user. The existing on-disk package MUST NOT be modified or corrupted. - Very large documents: For documents with tens of thousands of rows, the write process creates the entire database in memory (temporary file). If memory pressure is a concern, the implementation SHOULD write incrementally and monitor for memory warnings on iOS.
- Schema downgrade attempt: If a document's
user_versionis higher than the app's current schema version, the document MUST refuse to load and present an error indicating that a newer version of the app is required (see States table). - Temporary file cleanup failure: If the temporary database file cannot be deleted after reading its bytes, the operation SHOULD still succeed (the data was already captured). The leftover temp file will be cleaned up by the OS eventually.
- Package opened by external tool: If a user right-clicks "Show Package Contents" and modifies the SQLite database externally, the app has no mechanism to detect this. The next open will read whatever state the database is in. No integrity checking beyond schema version is performed.
- Empty settings table: If the settings table exists but contains no rows, all settings MUST fall back to their coded default values. This is not an error condition.
- Date parsing failures: If a date string in the metadata table does not conform to ISO 8601, the
invalidDateerror MUST be thrown and surfaced, rather than silently using a fallback date. - Multiple database files in package: If future versions add additional database files to the package (e.g.,
cache.db), the read/write process MUST handle the presence of unknown files gracefully — they are preserved in the directory FileWrapper during write.
Logging
Subsystem: {{bundle_id}} | Category: PackageDocument
| Event | Level | Message |
|---|---|---|
| Document opened (SQLite) | info | PackageDocument: opened "{{filename}}" (SQLite schema version {{version}}) |
| Document opened (legacy JSON) | info | PackageDocument: opened "{{filename}}" (legacy JSON format) |
| Document opened (empty package) | info | PackageDocument: opened "{{filename}}" (empty package, defaults applied) |
| Document created | info | PackageDocument: created new document "{{filename}}" |
| Write started | debug | PackageDocument: write started for "{{filename}}" |
| Temp database created | debug | PackageDocument: temp database created at "{{tempPath}}" |
| Data inserted | debug | PackageDocument: inserted {{rowCount}} rows into {{tableName}} |
| Temp database bytes read | debug | PackageDocument: read {{byteCount}} bytes from temp database |
| Temp database cleaned up | debug | PackageDocument: temp database deleted at "{{tempPath}}" |
| Write completed | debug | PackageDocument: write completed for "{{filename}}" |
| Legacy migration triggered | info | PackageDocument: migrating "{{filename}}" from legacy JSON to SQLite |
| Schema migration triggered | info | PackageDocument: migrating "{{filename}}" from schema version {{oldVersion}} to {{newVersion}} |
| Session URLs saved | debug | PackageDocument: saved {{count}} open document URLs for session restoration |
| Session restoration started | info | PackageDocument: restoring {{count}} documents from previous session |
| Session restoration failed for URL | warning | PackageDocument: failed to restore document at "{{url}}": {{error}} |
| Corrupt database detected | error | PackageDocument: corrupt database in "{{filename}}": {{error}} |
| Schema version too new | error | PackageDocument: "{{filename}}" has schema version {{version}}, app supports up to {{maxVersion}} |
| Disk full during write | error | PackageDocument: write failed for "{{filename}}": disk full or I/O error: {{error}} |
| SQLite open failed | error | PackageDocument: cannot open database at "{{path}}": {{error}} |
| SQL exec failed | error | PackageDocument: exec failed: {{sql}} — {{error}} |
| Temp file cleanup failed | warning | PackageDocument: failed to delete temp database at "{{tempPath}}": {{error}} |
| Date parsing failed | error | PackageDocument: invalid date string "{{dateString}}" in metadata table |
Platform Notes
- macOS (SwiftUI): Use
ReferenceFileDocumentwithDocumentGroup(newDocument:)for each document type. The@Published var modelpattern drives auto-save throughobjectWillChange. For file creation outside the standardDocumentGroupflow (e.g., "New Project" menu items), useNSSavePanelto choose a location and then programmatically create the package directory and initial database.NSWorkspacefile coordination applies automatically toDocumentGroup-managed documents. UTType declarations go in the target's Info.plist underUTExportedTypeDeclarations. UseFileWrapper(directoryWithFileWrappers:)for the package andFileWrapper(regularFileWithContents:)for each file inside it. - macOS (AppKit): Use
NSDocumentsubclass withoverride class var readableTypesandoverride class var writableTypes. Overrideread(from:ofType:)andfileWrapper(ofType:)with the same SQLite read/write logic.NSDocumentprovides auto-save for free whenautosavesInPlacereturnstrue. Package document support is enabled by returningtruefromclass var isNativeType(_:)for the custom UTType. - iOS:
ReferenceFileDocumentworks on iOS withDocumentGroup. The package is stored in the app's container or iCloud Drive. File coordination is handled by the system.NSSavePanelandNSOpenPanelare not available — use.fileImporter()and.fileExporter()modifiers instead. The same SQLite read/write logic applies. Note that iOS sandboxing requires security-scoped URL access for user-selected documents. - visionOS: Same as iOS.
DocumentGrouprenders document management UI in the visionOS window style. No platform-specific changes to the storage layer.
Design Decisions
Document creation flows are in menu-commands.md: This spec covers the read/write/migration lifecycle of package documents. The "New Project" and "New Workspace" creation flows (NSOpenPanel, git validation, NSSavePanel) are documented in ui/Recipes/menu-commands.md since they involve menu command structure and file picker UX, not just persistence.