Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apollo-ios/Design/cache-rewrite-phase1-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Phase 0 also produces two spike branches that are **not merged**:

Findings from each spike are captured in their respective Phase 0 ADRs (PR-003 references the SQLite spike; the cachecontrol-jsdirective spike findings become a `cache-rewrite/phase-0-adr-cachecontrol-spike` PR if material surprises surface — otherwise findings live as a comment thread on the existing ADR).

### Phase 1A — SQLite schema rewrite + field-aware `Record` (10 PRs)
### Phase 1A — SQLite schema rewrite + field-aware `Record` (11 PRs)

Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Published performance dataset accompanies the alpha tag.

Expand All @@ -251,7 +251,8 @@ Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Pub
| PR-006 | refactor(cache): change `Record.fields` type to `[CacheKey: CachedField]` | ⬜ | PR-005 | ~400 | Update existing Record/RecordSet tests; verify `record[key]` subscript still returns `Value?` for all existing call sites |
| PR-007 | feat(sqlite): add `schema_metadata` table and version detection | ⬜ | PR-006 | ~150 | Unit: schema-version read/write, missing-row defaults to 0, version stamping on init |
| PR-008 | feat(sqlite): new schema DDL — records table with composite PK + typed columns | ⬜ | PR-007 | ~200 | Unit: table creation idempotent, `WITHOUT ROWID` preserved, schema_metadata version=3 stamped |
| PR-009 | feat(sqlite): implement insert/select/update/delete on new table (feature-flagged) | ⬜ | PR-008 | ~600 | Unit: each operation against new schema; round-trip Record↔rows; transactional behavior on failure; performance smoke test |
| PR-008b | feat(sqlite): replace records DDL with position-keyed v4 schema per [ADR 0006](./adr/0006-list-storage-strategy.md) | ⬜ | PR-008 | ~150 | Unit: new table creation idempotent with extended PK `(cache_key, field_name, position)` and `position INTEGER NOT NULL DEFAULT -1`; `WITHOUT ROWID` preserved; schema_metadata version=4 stamped; migration trigger updated to drop on `< 4` and absorbs the v3 → v4 rebuild path; PR-007/PR-008 tests updated for the new column shape and PK |
| PR-009 | feat(sqlite): row-per-element CRUD against position-keyed schema | ⬜ | PR-008b | ~600 | Unit: each operation against the position-keyed schema per [ADR 0006](./adr/0006-list-storage-strategy.md); round-trip Record↔rows for scalar and list-typed fields (position-aware encoder + decoder); nested-list synthetic sub-record recursion at depth ≥ 2; atomic list-element rewrite (no partial-list states); performance smoke test. Note: this PR supersedes the original "JSON list_value" scope; the row-per-field CRUD harness from the prior implementation is retained, the JSON list-encoding branches in `SQLiteFieldEncoding.swift` are replaced. |
| PR-010 | feat(sqlite): switch `SQLiteNormalizedCache` to new schema; drop-and-rebuild migration | ⬜ | PR-009 | ~400 | Unit: migration on detected old schema; integration: existing cache tests pass on new schema; CachePersistenceTests updated |
| PR-011 | test(cache): SQLite performance-gate harness on iPhone 16 Pro | ⬜ | PR-010 | ~200 | Performance test asserting all §7.4 gates within 25% margin |
| PR-011a | feat(cache): comprehensive performance measurement harness (Tier 1 + Tier 2) | ⬜ | PR-011 | ~700 | Unit: each Tier 1 and Tier 2 scenario runs cleanly; JSON exporter produces well-formed output; harness is re-runnable across versions |
Expand Down
24 changes: 14 additions & 10 deletions apollo-ios/Design/cache-rewrite-phase1-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,19 +281,21 @@ public enum Source: Sendable {

### 7.1 New schema (DDL)

The schema is row-per-element: each scalar field is one row and each list element is one row, all in the same `records` table. The `position` column distinguishes them — `-1` for scalars, `0..N-1` for list elements — and is part of the primary key. List-element rows live at the same `cache_key` as their parent record's scalar fields and cluster physically next to them on disk via `WITHOUT ROWID`. Per [ADR 0006](./adr/0006-list-storage-strategy.md), nested lists (`[[T]]`) recurse via `child_key_value` indirection to synthetic sub-records that themselves use the same layout.

```sql
CREATE TABLE IF NOT EXISTS records (
cache_key TEXT NOT NULL,
field_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT -1, -- -1 = scalar; 0..N-1 = list element
int_value INTEGER,
string_value TEXT,
float_value REAL,
bool_value INTEGER,
list_value TEXT, -- JSON-encoded list
child_key_value TEXT, -- cache reference
child_key_value TEXT, -- cache reference (or synthetic sub-record key for nested lists; see ADR 0006)
custom_scalar_value TEXT, -- JSON-encoded
written_at INTEGER NOT NULL,
PRIMARY KEY (cache_key, field_name)
PRIMARY KEY (cache_key, field_name, position)
) WITHOUT ROWID;
```

Expand All @@ -304,14 +306,16 @@ CREATE TABLE IF NOT EXISTS schema_metadata (
key TEXT PRIMARY KEY,
value TEXT
);
-- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '3');
-- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '4');
```

The version bump from 3 to 4 corresponds to ADR 0006's adoption of the row-per-element layout. v3 (the JSON `list_value` shape predecessor) was never tagged externally; the bump exists to give any local dev databases that ran the v3 shape on the plan branch a clean rebuild path on next launch.

### 7.2 Operations

- `selectRecords(forKeys:)` — single `SELECT … WHERE cache_key IN (?, ?, …) ORDER BY cache_key, field_name`. Reassembles into `Record` instances by grouping by `cache_key` in Swift. Composite-PK clustering ensures rows for one record arrive contiguous in the result set.
- `addOrUpdate(records:)` — shreds each `Record.fields` into N row UPSERTs in one transaction. Each row carries its `written_at`.
- `deleteRecord(for:)` — `DELETE FROM records WHERE cache_key = ?`.
- `selectRecords(forKeys:)` — single `SELECT … WHERE cache_key IN (?, ?, …) ORDER BY cache_key, field_name, position`. Reassembles into `Record` instances by grouping by `cache_key` in Swift; the decoder branches on `position` to dispatch scalar rows (`position = -1`) and list-element rows (`position >= 0`, accumulated in order). Composite-PK clustering ensures rows for one record arrive contiguous in the result set, with scalar and list-element rows for a given field arriving as a contiguous run.
- `addOrUpdate(records:)` — shreds each `Record.fields` into row UPSERTs in one transaction. Scalar fields produce one row at `position = -1`; list-typed fields produce N rows at `position = 0..N-1`. Each row carries its `written_at`. List-element rows for a field are rewritten atomically — an update to a list-typed field deletes the existing element rows and inserts the new ones in the same transaction, so partial-list states are not observable.
- `deleteRecord(for:)` — `DELETE FROM records WHERE cache_key = ?`. Scalar rows and depth-1 list-element rows delete in the same statement (they share the cache_key). Nested-list sub-records at depth ≥ 2 live at synthetic keys (`<parent>.<field>[N]` per [ADR 0006](./adr/0006-list-storage-strategy.md)) and require a small cascading reachability walk that follows `child_key_value` columns.
- `deleteRecords(matching:)` — unchanged semantics (`WHERE cache_key LIKE ? COLLATE NOCASE`).
- `clearDatabase` — unchanged.

Expand All @@ -320,10 +324,10 @@ CREATE TABLE IF NOT EXISTS schema_metadata (
On `init`, after `createRecordsTableIfNeeded`:

1. Read `schema_metadata` for the version.
2. If version is missing or `< 3`, drop and recreate the records table; insert the new version.
3. If version is `3`, no migration needed.
2. If version is missing or `< 4`, drop and recreate the records table; insert the new version.
3. If version is `4`, no migration needed.

The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads.
The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. The migration trigger absorbs both genuine upgrades from 2.x (no `schema_metadata` row at all) and local-dev databases that ran the v3 shape on the plan branch before [ADR 0006](./adr/0006-list-storage-strategy.md) (version row reads `3`). v3 never tagged externally, so no end-user installation is on v3.

### 7.4 Performance gates

Expand Down
Loading