feat(sqlite): row-per-element CRUD against position-keyed schema (PR-009) - #1001
Open
AnthonyMDev wants to merge 5 commits into
Open
feat(sqlite): row-per-element CRUD against position-keyed schema (PR-009)#1001AnthonyMDev wants to merge 5 commits into
AnthonyMDev wants to merge 5 commits into
Conversation
✅ Docs preview readyThe preview is ready to be viewed. View the preview File Changes 0 new, 1 changed, 0 removedBuild ID: 53b673f6969547f6647e2feb URL: https://www.apollographql.com/docs/deploy-preview/53b673f6969547f6647e2feb ✅ AI Style Review — No Changes DetectedNo MDX files were changed in this pull request. Review Log: View detailed log
|
7 tasks
5 tasks
AnthonyMDev
added a commit
that referenced
this pull request
May 28, 2026
Status flipped from Proposed to Accepted. The sibling `list_items` table moves out of the active option space into "Alternatives considered" alongside the JSON `list_value` rejection. The chosen layout dominates the sibling table on every dimension that matters — read locality (one table, clustered via WITHOUT ROWID), schema surface (one table, one migration target), nested-list handling (reuses existing CacheKey indirection rather than requiring a new depth strategy), and Phase 2 cascade walker source — so the benchmark-driven lock no longer carries its weight. The list-heavy benchmark scenarios still land in PR-011a, but as permanent regression coverage of the chosen design rather than as decision-gating evidence. Restructured to the canonical ADR format used by ADR 0001-0005: Context, Decision, Alternatives considered, Consequences, References. Separate Trade-offs and Deciding evidence sections collapse into the Decision section's rationale and the Alternatives considered rejections. Implementation impact documented inline: - §7.1 DDL and §7.2 operations need a follow-up doc PR to match the new schema (column changes, PK extension to (cache_key, field_name, position), DEFAULT -1 sentinel for scalar rows). - PR-009 (#1001, open) scope amended: position-aware row writers replace the JSON list-encoding branches; decoder grows a position = -1 / position >= 0 split. PR-009 review-findings hardening is retained where it applies to custom_scalar_value. - 3.0-alpha is untagged, so the §7.1 schema change carries zero migration cost — the drop-and-rebuild path on first 3.0 launch carries this ADR's schema directly. - Execution plan §8 needs a follow-up to reflect the amended PR-009 scope. 120 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 28, 2026
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
May 29, 2026 19:34
87e76bf to
f5f78b7
Compare
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 1, 2026 18:31
f5f78b7 to
96e39f4
Compare
6 tasks
AnthonyMDev
commented
Jun 1, 2026
| cacheKey: CacheKey, | ||
| fieldName: String, | ||
| position: Int64, | ||
| column: SQLiteFieldEncoding.Column, |
Contributor
Author
There was a problem hiding this comment.
Calling this parameter column is confusing to me. What about typedValue?
| /// into `Record` instances. Follows synthetic `child_key_value` | ||
| /// pointers to materialize nested lists. | ||
| private func loadRecordBatch(_ keys: Set<CacheKey>) throws -> [Record] { | ||
| let placeholders = keys.map { _ in "?" }.joined(separator: ", ") |
Contributor
Author
There was a problem hiding this comment.
Can we use a repeating count to do this instead of mapping? Should be faster.
AnthonyMDev
added a commit
that referenced
this pull request
Jun 1, 2026
…aceholders Addresses review comments on PR #1001: - Rename SQLiteFieldEncoding.Column → TypedValue, and upsertRow's `column:` parameter → `typedValue:`. "Column" was overloaded with the SQL sense (a database column); the renamed type makes it clearer that the enum is a Record.Value already classified into the destination column slot it'll be bound to. - Replace `keys.map { _ in "?" }.joined(separator: ", ")` with `Array(repeating: "?", count: keys.count).joined(separator: ", ")` in loadRecordBatch. The bulk-fill initializer skips the per- element closure invocation. Doc comments updated where they referred to "the column slot" / "selects the column slot" to match the new name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
added a commit
that referenced
this pull request
Jun 1, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
commented
Jun 1, 2026
Comment on lines
+30
to
+39
| private func totalRowCount(_ db: ApolloSQLiteDatabase) throws -> Int { | ||
| let dbURL = SQLiteTestCacheProvider.temporarySQLiteFileURL() | ||
| _ = dbURL // unused; rowCount runs via the internal-test select helper | ||
| // The cheapest way without exposing more internals is to load | ||
| // every known key and inspect — but we don't know all keys. | ||
| // Instead, use clearDatabase as a destructive end-of-test check | ||
| // where appropriate. For row-count assertions, the tests | ||
| // explicitly select the keys they wrote and check the returned | ||
| // structure. | ||
| fatalError("Use selectRecords to assert structure; this helper is unused") |
Contributor
Author
There was a problem hiding this comment.
If unused, why is this not deleted?
AnthonyMDev
added a commit
that referenced
this pull request
Jun 1, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Jun 1, 2026
AnthonyMDev
added a commit
that referenced
this pull request
Jun 2, 2026
…aceholders Addresses review comments on PR #1001: - Rename SQLiteFieldEncoding.Column → TypedValue, and upsertRow's `column:` parameter → `typedValue:`. "Column" was overloaded with the SQL sense (a database column); the renamed type makes it clearer that the enum is a Record.Value already classified into the destination column slot it'll be bound to. - Replace `keys.map { _ in "?" }.joined(separator: ", ")` with `Array(repeating: "?", count: keys.count).joined(separator: ", ")` in loadRecordBatch. The bulk-fill initializer skips the per- element closure invocation. Doc comments updated where they referred to "the column slot" / "selects the column slot" to match the new name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 2, 2026 21:02
ef28685 to
b63c35a
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 2, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Jun 2, 2026
AnthonyMDev
added a commit
that referenced
this pull request
Jun 9, 2026
…aceholders Addresses review comments on PR #1001: - Rename SQLiteFieldEncoding.Column → TypedValue, and upsertRow's `column:` parameter → `typedValue:`. "Column" was overloaded with the SQL sense (a database column); the renamed type makes it clearer that the enum is a Record.Value already classified into the destination column slot it'll be bound to. - Replace `keys.map { _ in "?" }.joined(separator: ", ")` with `Array(repeating: "?", count: keys.count).joined(separator: ", ")` in loadRecordBatch. The bulk-fill initializer skips the per- element closure invocation. Doc comments updated where they referred to "the column slot" / "selects the column slot" to match the new name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 9, 2026 18:26
b63c35a to
4a05d29
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 9, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…009) Lays down the row-per-element write + delete CRUD against the position-keyed schema from PR-008b (#1005). The JSON list-encoding path is gone; each list element is a typed-column row at `position = 0..N-1` keyed under its parent's cache_key + field_name, with `WITHOUT ROWID` clustering keeping elements physically adjacent to their parent's scalar rows. Public API on `SQLiteDatabase`: - `insertOrUpdate(records: [Record])` — for each field, scalar values produce one row at `position = -1`, list-typed values produce N rows at positions `0..N-1`. Each write atomically clears the field's prior rows before inserting the new ones, so list rewrites and scalar↔list transitions never leave in-field orphans. - `deleteRecord(forKey:)` — direct `DELETE WHERE cache_key = ?`. - `deleteRecords(matchingKey:)` — `LIKE … ESCAPE '\'` with `\`, `%`, `_` in the user-supplied pattern escaped so literal substrings like `User_` don't act as wildcards. Nested-list elements (`[[Int]]`, `[[CacheReference]]`) recurse via synthetic sub-records at `<parent>.<field>.$[<position>]`. Deeper nesting appends `.$[<position>]` per level. Synthetic sub-records hold their inner list's element rows under the sentinel `field_name = "$"`. The `$` character cannot start a GraphQL Name, so the suffix shape cannot collide with any cache key produced from real GraphQL schemas. **Deferred to a follow-up PR**: cascading deletion of synthetic sub- records. The current implementation: - `deleteRecord(forKey:)` deletes only the record's own rows; the record's nested-list synthetic sub-records remain in the database as unreachable orphans. - `insertOrUpdate` atomic-rewrite clears the field's own rows but not the synthetic sub-records the prior rows pointed at; those also orphan. - `deleteRecords(matchingKey:)` uses a flat `LIKE` filter and may or may not match synthetic sub-records depending on the pattern. Reads are unaffected — orphans never surface through `selectRecords` because they're unreachable from any non-synthetic cache key. The cleanup walk lands in a follow-up PR with extensive cascade- correctness tests (depth-1/2/3 cascade, cascade isolation across records, cascade-doesn't-follow-real-CacheReferences, mixed-content lists, pattern-delete + synthetic interaction). `SQLiteFieldEncoding` is the per-value encoder/decoder. Single (non-array) values dispatch to one of the six typed columns: `NSNumber`-bool via `CFGetTypeID` → `bool_value`; integer NSNumber → `int_value`; floating NSNumber → `float_value`; `String` → `string_value`; `CacheReference` → `child_key_value`; everything else (incl. `NSNull` and generic dicts) JSON-encodes to `custom_scalar_value` with `.sortedKeys + .fragmentsAllowed`. Array inputs are precondition-violated — they belong at the database layer's iteration, not in the encoder. A test-only `internal selectRecords(forKeys:)` reads back what `insertOrUpdate` wrote, follows synthetic `child_key_value` pointers to reassemble nested lists, and filters out synthetic sub-records from the top-level result. Not on the public protocol — the public read path will be the projection-aware API introduced in PR-009b–h per ADR 0007. `SQLiteRowPerElementCRUDTests` (new): 32 tests covering per-type round-trip, list (scalars / references / mixed / empty), 2D / 3D / list-of-list-of-references nested lists, multi-field record, UPSERT semantics (write-twice overwrite, scalar value-type change clearing prior column), atomic list rewrite that shrinks length, delete by key (matching + non-existent), delete by pattern (incl. `_` and `%` escapes), transactional rollback (scalar + list element encoding failures), NSNull at top level, generic dict round-trip, `$reference` with extra keys treated as generic dict, NSNumber bool/int/double routing, empty-record produces no rows. Cascade-delete-specific tests are deferred to the follow-up PR along with the implementation. Full Apollo-UnitTestPlan: 1037 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aceholders Addresses review comments on PR #1001: - Rename SQLiteFieldEncoding.Column → TypedValue, and upsertRow's `column:` parameter → `typedValue:`. "Column" was overloaded with the SQL sense (a database column); the renamed type makes it clearer that the enum is a Record.Value already classified into the destination column slot it'll be bound to. - Replace `keys.map { _ in "?" }.joined(separator: ", ")` with `Array(repeating: "?", count: keys.count).joined(separator: ", ")` in loadRecordBatch. The bulk-fill initializer skips the per- element closure invocation. Doc comments updated where they referred to "the column slot" / "selects the column slot" to match the new name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the enum case name with the schema column name (`SQLiteSchema.Records.floatValue` = `float_value`). SQLite's storage type name is REAL, but the column-level name we use throughout the codebase is `float_value`/`floatValue`; matching the case to that name keeps the read/write code self-consistent. Affects the case declaration in `SQLiteFieldEncoding.TypedValue`, the `NSNumber` floating-point routing in `encode(_:)`, and the single consumer site (the `switch typedValue` binding loop in `ApolloSQLiteDatabase.upsertRow`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 29, 2026 20:38
4a05d29 to
b715e5e
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 29, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dead code — unconditionally fatalError'd and was never called; tests assert structure via selectRecords directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Empty lists previously wrote zero rows, making a cached `[]` indistinguishable from a never-written field — and an empty *nested* list left a dangling synthetic reference that read back as a raw CacheReference. Both now round-trip: an empty list writes a single marker row at position = -2 with no value column populated, and the read path decodes it back to `[]` (including through synthetic sub-record resolution). Also lands the reserved-key audit promised by ADR 0006: insertOrUpdate rejects cache keys containing the reserved synthetic token `.$[` before opening the write transaction, so the synthetic-key classifiers (regex and SQL LIKE) can never match a stored user record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AnthonyMDev
added a commit
that referenced
this pull request
Jul 9, 2026
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Land the amended PR-009 scope per ADR 0006: row-per-element write + delete CRUD against the position-keyed schema introduced in PR-008b (#1005). The JSON
list_valuepath is gone; nested lists recurse via synthetic sub-records at<parent>.<field>.$[<position>].Cascading deletion of synthetic sub-records is split into a follow-up PR with extensive cascade-correctness tests. This PR establishes the foundation; the cascade PR ships the cleanup walks.
What's in this PR
Public API on
SQLiteDatabaseinsertOrUpdate(records: [Record])position = -1; list → N rows at positions0..N-1. Each write atomically clears the field's prior rows before inserting the new ones, so list rewrites and scalar↔list transitions never leave in-field orphans.deleteRecord(forKey:)DELETE WHERE cache_key = ?. Does not cascade synthetic sub-records (see below).deleteRecords(matchingKey:)LIKE … COLLATE NOCASE ESCAPE '\\'with\,%,_in the user-supplied pattern escaped so literal substrings don't act as wildcards. Does not cascade synthetic sub-records.Synthetic sub-records for nested lists
Per ADR 0006 + the design conversation:
child_key_value = "<parent>.<field>.$[N]"pointing at a synthetic sub-record.field_name = "$"(sentinel —$can't start a GraphQL Name, so no collision with real fields).[[[T]]]) appends.$[N]per level:User:1.matrix3d.$[0].$[0].$[0]is the level-3 sub-record's key.\.\$\[[0-9]+\]$.Encoder (
SQLiteFieldEncoding)Per-value, never per-array. Lists are exploded into rows at the database layer.
NSNumber(boolean viaCFGetTypeID == CFBooleanGetTypeID)bool_valueNSNumber(integer kinds viaCFNumberGetType)int_valueNSNumber(other numeric kinds)float_valueStringstring_valueCacheReferencechild_key_value(stores.key)NSNulland any other JSON-shaped valuecustom_scalar_value(.sortedKeys + .fragmentsAllowedJSON)[Record.Value]The custom-scalar fallback gates on
JSONSerialization.isValidJSONObject([value])before encoding so an unsupported type surfaces a Swift error rather than anNSExceptionabort. The$referencedict-wrapper is recognized only when the dict has exactly one key, so a legitimate custom scalar that happens to include$referencealongside other keys round-trips intact.Test-only read path
A non-public
internal selectRecords(forKeys:)reassembles records from rows and resolves syntheticchild_key_valuepointers to materialize nested lists. Used by the test suite to verify whatinsertOrUpdatewrote. The production read path is a projection-aware API to be introduced in PR-009b–h per ADR 0007 — not on the public protocol.Tests
SQLiteRowPerElementCRUDTests.swift(new file) — 32 tests, all passing:writtenAt_and%escaped$referencewith extra keys treated as generic dictWhat's deferred to the follow-up cascade PR
Three places where synthetic sub-record orphans can accumulate under this PR's behavior:
deleteRecord(forKey:)— deletes the record's own rows but leaves any synthetic sub-records the record's nested-list fields pointed at. They become unreachable but persistent.insertOrUpdateatomic-rewrite over a nested-list field — clears the field's own rows but not the synthetic sub-records the prior rows pointed at. They orphan.deleteRecords(matchingKey:)+ synthetic sub-records — the flatLIKEfilter may or may not match synthetic sub-records depending on the pattern. Behavior is currently ambiguous; the cascade PR pins it down.The cascade PR will:
cascadeDeleteFromCacheKeyandcascadeDeleteFromFieldCTEs (and aLIKE-pattern variant fordeleteRecords(matchingKey:)).selectRecordsso orphan detection actually verifies the database state (the previous cascade tests on this PR's branch passed regardless of cascade behavior becauseselectRecordsfiltered synthetic keys before the assertions ran — a real test bug surfaced during review).CacheReference) — cascade follows synthetic onlyCacheReferenceinside a synthetic sub-record — that ref's target NOT cascadeddeleteRecords(matchingKey:)+ synthetic interaction (with a pinned-down behavior)The deferred cascade and its tests don't gate this PR's correctness for the operations it ships —
selectRecordsnever returns orphan synthetic sub-records as top-level records, so the read surface is unaffected. The cascade is about storage hygiene + the eventualclearDatabasecleanliness contract.Acceptance criteria
Apollo-UnitTestPlanis green.selectRecordsis not on the public protocol — only aninternalhelper.insertOrUpdateanddeleteRecord(forKey:)clearly call out the deferred cascade.Stacks on
cache-rewrite/phase-1-planate06a2b859(the PR-008b merge commit).Followup
FieldProjectiontypes), PR-009c–h.