Skip to content

Commit d0b9b52

Browse files
feat: add Capacitor SQLite persisted collection package (#1363)
* feat: add capacitor sqlite persisted collection package Add a Capacitor SQLite runtime wrapper so persisted collections can reuse the shared core adapter and test coverage pattern across runtimes. Made-with: Cursor * feat: add native capacitor sqlite e2e harness Move the Capacitor persisted-collection runtime harness into the package, run the full conformance suite in a native iOS app, and share the suite registration lifecycle across the shimmed and native e2e paths. Made-with: Cursor * feat: add android capacitor e2e harness Extend the shared Capacitor test app to support Android, add a host runner that boots or reuses an emulator and reads the native SQLite results database, and document the new Android e2e workflow. Made-with: Cursor * fix: harden native capacitor e2e harness Pass the detected Android SDK through native build commands, guard the Android runner's piped subprocess output, and align the shared harness teardown hook with the e2e contract's real cleanup path. Made-with: Cursor * ci: apply automated fixes * fix: harden capacitor process detection and sync lockfile Guard Node AsyncLocalStorage detection for polyfilled process objects in Capacitor webviews, and document close() ownership semantics. Add a regression test and refresh pnpm-lock.yaml so workspace installs are reproducible. Made-with: Cursor * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5bdc263 commit d0b9b52

34 files changed

Lines changed: 4161 additions & 22 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# @tanstack/db-capacitor-sqlite-persisted-collection
2+
3+
Thin SQLite persistence for Capacitor apps using
4+
`@capacitor-community/sqlite`.
5+
6+
## Public API
7+
8+
- `createCapacitorSQLitePersistence(...)`
9+
- `persistedCollectionOptions(...)` (re-exported from core)
10+
11+
## Install
12+
13+
```bash
14+
pnpm add @tanstack/db-capacitor-sqlite-persisted-collection @capacitor-community/sqlite
15+
```
16+
17+
## Quick start
18+
19+
```ts
20+
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
21+
import { createCollection } from '@tanstack/db'
22+
import { QueryClient } from '@tanstack/query-core'
23+
import { queryCollectionOptions } from '@tanstack/query-db-collection'
24+
import {
25+
createCapacitorSQLitePersistence,
26+
persistedCollectionOptions,
27+
} from '@tanstack/db-capacitor-sqlite-persisted-collection'
28+
29+
type Todo = {
30+
id: string
31+
title: string
32+
completed: boolean
33+
}
34+
35+
const sqlite = new SQLiteConnection(CapacitorSQLite)
36+
const database = await sqlite.createConnection(
37+
`tanstack-db`,
38+
false,
39+
`no-encryption`,
40+
1,
41+
false,
42+
)
43+
44+
await database.open()
45+
46+
// One shared persistence instance for the whole database.
47+
const persistence = createCapacitorSQLitePersistence({
48+
database,
49+
})
50+
const queryClient = new QueryClient()
51+
52+
export const todosCollection = createCollection(
53+
persistedCollectionOptions<Todo, string>({
54+
...queryCollectionOptions({
55+
queryKey: [`todos`],
56+
queryFn: async () => {
57+
const response = await fetch(`/api/todos`)
58+
return response.json() as Promise<Array<Todo>>
59+
},
60+
queryClient,
61+
getKey: (todo) => todo.id,
62+
}),
63+
id: `todos`,
64+
persistence,
65+
schemaVersion: 1, // Per-collection schema version
66+
}),
67+
)
68+
```
69+
70+
## Platform notes
71+
72+
- `createCapacitorSQLitePersistence` is shared across collections.
73+
- Mode defaults (`sync-present` vs `sync-absent`) are inferred from whether a
74+
`sync` config is present in `persistedCollectionOptions`.
75+
- This package assumes you provide an already-created and opened
76+
`SQLiteDBConnection`.
77+
- Calling `close()` on this driver's database adapter closes the underlying
78+
`SQLiteDBConnection`, so treat driver ownership as connection ownership.
79+
- This package targets native Capacitor runtimes. Use the dedicated browser and
80+
Electron persistence packages instead of the plugin's web or Electron modes.
81+
- The plugin's database version and upgrade APIs are separate from TanStack DB
82+
`schemaVersion`.
83+
- `@capacitor-community/sqlite` uses SQLCipher-backed native builds, so app
84+
setup may require additional platform configuration outside this package.
85+
- Capacitor 8 creates iOS projects with Swift Package Manager by default, but
86+
`@capacitor-community/sqlite` currently links through CocoaPods on iOS. Add
87+
iOS with `npx cap add ios --packagemanager CocoaPods`, or recreate an SPM
88+
project in CocoaPods mode before expecting the native plugin to load.
89+
90+
## Testing
91+
92+
- `pnpm --filter @tanstack/db-capacitor-sqlite-persisted-collection test:e2e`
93+
runs the package e2e suite against the default `better-sqlite3` harness.
94+
- `pnpm --filter @tanstack/db-capacitor-sqlite-persisted-collection test:e2e:ios`
95+
builds the package-local Capacitor harness in `e2e/app`, launches the iOS
96+
simulator, and runs the full persisted collection e2e suite inside the real
97+
native Capacitor runtime.
98+
- `pnpm --filter @tanstack/db-capacitor-sqlite-persisted-collection test:e2e:android`
99+
builds the same package-local Capacitor harness in `e2e/app`, launches an
100+
Android emulator or uses a connected debug target, and runs the full
101+
persisted collection e2e suite inside the real native Capacitor runtime.
102+
- `test:e2e:ios` is a repo-local validation path for this package. It depends on
103+
the checked-in `e2e/app` harness plus local Capacitor/Xcode tooling.
104+
- `test:e2e:android` depends on a local Android SDK. The runner auto-detects the
105+
default macOS SDK location, boots the first available AVD when needed, and
106+
reads the app-owned SQLite result database out of the debug sandbox with
107+
`adb run-as`.
108+
- The native harness lives under `e2e/app` so the same app can be extended to
109+
other native targets later, including Android.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
android/
3+
ios/
4+
node_modules/
5+
ios-e2e-screenshot.png
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { CapacitorConfig } from '@capacitor/cli'
2+
3+
const config: CapacitorConfig = {
4+
appId: `com.tanstack.db.capacitorsqlitee2e`,
5+
appName: `TanStackDBCapacitorE2E`,
6+
webDir: `dist`,
7+
server: {
8+
androidScheme: `https`,
9+
},
10+
}
11+
12+
export default config
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Capacitor SQLite E2E</title>
7+
</head>
8+
<body>
9+
<main>
10+
<h1>Capacitor SQLite E2E</h1>
11+
<p id="status">Booting...</p>
12+
<pre id="details"></pre>
13+
</main>
14+
<script type="module" src="./src/main.ts"></script>
15+
</body>
16+
</html>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@tanstack/db-capacitor-sqlite-persisted-collection-e2e-app",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"build": "vite build",
8+
"dev": "vite",
9+
"native:add:android": "cap add android",
10+
"native:add:ios": "cap add ios --packagemanager CocoaPods",
11+
"native:sync:android": "cap sync android",
12+
"native:sync:ios": "cap sync ios"
13+
},
14+
"dependencies": {
15+
"@capacitor-community/sqlite": "^8.0.1",
16+
"@capacitor/core": "^8.2.0",
17+
"@tanstack/db": "workspace:*",
18+
"@tanstack/db-capacitor-sqlite-persisted-collection": "workspace:*"
19+
},
20+
"devDependencies": {
21+
"@capacitor/android": "^8.2.0",
22+
"@capacitor/cli": "^8.2.0",
23+
"@capacitor/ios": "^8.2.0",
24+
"@types/node": "^25.2.2",
25+
"typescript": "^5.9.3",
26+
"vite": "^7.3.1"
27+
}
28+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Capacitor } from '@capacitor/core'
2+
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
3+
import { createNativeCapacitorSQLiteTestDatabaseFactory } from './native-capacitor-sqlite-test-db'
4+
import { registerCapacitorNativeE2ESuite } from './register-capacitor-e2e-suite'
5+
import {
6+
getRegisteredTestCount,
7+
resetRegisteredTests,
8+
runRegisteredTests,
9+
} from './runtime-vitest'
10+
11+
const statusElement = document.querySelector(`#status`) as HTMLParagraphElement
12+
const detailsElement = document.querySelector(`#details`) as HTMLPreElement
13+
const runtimeRunId =
14+
import.meta.env.VITE_TANSTACK_DB_CAPACITOR_E2E_RUN_ID ??
15+
Date.now().toString(36)
16+
const resultsDatabaseName = `tanstack_db_capacitor_e2e_results_${runtimeRunId}`
17+
18+
function setStatus(status: string, details?: unknown): void {
19+
statusElement.textContent = status
20+
if (details !== undefined) {
21+
detailsElement.textContent = JSON.stringify(details, null, 2)
22+
}
23+
}
24+
25+
async function persistRunResult(
26+
sqlite: SQLiteConnection,
27+
result: {
28+
status: `passed` | `failed`
29+
payload: unknown
30+
},
31+
): Promise<void> {
32+
const resultsDatabase = await sqlite.createConnection(
33+
resultsDatabaseName,
34+
false,
35+
`no-encryption`,
36+
1,
37+
false,
38+
)
39+
40+
try {
41+
await resultsDatabase.open()
42+
await resultsDatabase.execute(
43+
`CREATE TABLE IF NOT EXISTS test_run_results (
44+
id INTEGER PRIMARY KEY AUTOINCREMENT,
45+
status TEXT NOT NULL,
46+
payload_json TEXT NOT NULL
47+
);`,
48+
false,
49+
)
50+
await resultsDatabase.run(`DELETE FROM test_run_results`, [], false)
51+
await resultsDatabase.run(
52+
`INSERT INTO test_run_results (status, payload_json) VALUES (?, ?)`,
53+
[result.status, JSON.stringify(result.payload)],
54+
false,
55+
)
56+
} finally {
57+
try {
58+
await resultsDatabase.close()
59+
} catch {}
60+
61+
try {
62+
await sqlite.closeConnection(resultsDatabaseName, false)
63+
} catch {}
64+
}
65+
}
66+
67+
async function run(): Promise<void> {
68+
const platform = Capacitor.getPlatform()
69+
const sqlite = new SQLiteConnection(CapacitorSQLite)
70+
71+
setStatus(`Starting native e2e runtime on ${platform}`)
72+
73+
if (platform === `web`) {
74+
setStatus(`Expected a native Capacitor runtime but got web`, {
75+
platform,
76+
runId: runtimeRunId,
77+
})
78+
return
79+
}
80+
81+
try {
82+
resetRegisteredTests()
83+
registerCapacitorNativeE2ESuite({
84+
suiteName: `capacitor persisted collection conformance`,
85+
createDatabase: createNativeCapacitorSQLiteTestDatabaseFactory({
86+
sqlite,
87+
runId: runtimeRunId,
88+
}),
89+
})
90+
91+
const totalTests = getRegisteredTestCount()
92+
setStatus(`Running native e2e suite`, {
93+
totalTests,
94+
runId: runtimeRunId,
95+
})
96+
97+
const result = await runRegisteredTests({
98+
onTestStart: ({ index, name, total }) => {
99+
setStatus(`Running test ${String(index)}/${String(total)}`, {
100+
currentTest: name,
101+
})
102+
},
103+
})
104+
105+
const failedResults = result.results.filter(
106+
(entry) => entry.status === `failed`,
107+
)
108+
const summary = {
109+
passed: result.passed,
110+
failed: result.failed,
111+
skipped: result.skipped,
112+
total: result.total,
113+
failures: failedResults.slice(0, 10),
114+
}
115+
116+
if (result.failed > 0) {
117+
await persistRunResult(sqlite, {
118+
status: `failed`,
119+
payload: summary,
120+
})
121+
setStatus(`Native e2e failed`, summary)
122+
return
123+
}
124+
125+
await persistRunResult(sqlite, {
126+
status: `passed`,
127+
payload: summary,
128+
})
129+
setStatus(`Native e2e passed`, summary)
130+
} catch (error) {
131+
const message = error instanceof Error ? error.message : String(error)
132+
133+
try {
134+
await persistRunResult(sqlite, {
135+
status: `failed`,
136+
payload: {
137+
error: message,
138+
runId: runtimeRunId,
139+
step: statusElement.textContent,
140+
},
141+
})
142+
} catch {}
143+
144+
setStatus(`Native e2e failed: ${message}`, {
145+
step: statusElement.textContent,
146+
runId: runtimeRunId,
147+
})
148+
}
149+
}
150+
151+
void run()

0 commit comments

Comments
 (0)