Skip to content

Commit 0d85771

Browse files
boutellThomas Boutell
andauthored
Postgres (#5365)
* postgres experimental WIP * astonishingly, all mocha tests of apostrophe pass with this * mocha tests pass, actual sites work * lint clean * listDatabases support, but changes are coming * wip * dump and restore updates * backpressure, adequate handling of ObjectId for our needs (becomes its hex representation) * mild performance optimization * profiling * testing issue resolved * refactored to db-connect module, introduced sqlite adapter * sqlite WIP * debugging * programmatic API for dump/restore/copy dbs * linting, documentation * MIT license * text ranking is more accurate, documentation is more complete * good full text search for sqlite * updates for compatibility with the rest of the public and private modules, plus a few fixes to genuinely ambiguous tests * requirements found by testing private modules * fixes from full cypress run * eslint passing * restore permissions * maximize atomicity * bug fixes * * exit properly when asset tests fail * "npm test" tests all three adapters * ignore claude-tools in eslint * postgres and sqlite-inclusive ci matrix attempt * clean up logs * We hit github's limit on total configurations because every package gets its own matrix. Solve that with grouping: * apostrophe core * All regular ecosystem packages other than core * non-database-requiring packges * mongodb-specific packages This will probably speed it up too because it won't have to spin up a container a bazillion times. * hardened the asset tests, made them less timing sensitive, fixed a bad commit resulting from the way they dodgily patch themselves without a robust cleanup mechanism * fix a root cause of asset test instability * log mess * implemented missing $size operator * test compatibility * advanced permission uses regex in $in * regex in $in * .db() should not make false promises in plain postgres mode, it should fail * ability to specify a default adapter * obsolete file * put escapeHost back where it belongs * dead code removal, test cleanup * emulate-mongo-3-driver only needed in db-connect * no claude logs in repo (tools are welcome) * * shared aggregation implementation, other shared things * optimize $match when it is the first step in aggregation, don't fetch the whole collection 😜 * multipostgres listDatabases() and .db() should return and expect "fully qualified virtual database names," e.g. physical_db_name-schemaname * vanilla postgres should not attempt to use .db() with alternate names in tests * documentation corrections * documentation errors * listDatabases and documentation corrections * more edge cases revealed by latest work from Miro * anchored prefix regexps are optimized documentation improvements * * matchesQuery in the aggregation cursor implementation doesn't throw on unrecognized operators. It should, and it should support the same mongodb operators that the regular find() path does in postgres/sqlite (our official subset), unless there is an extraordinary reason not to. * Similarly, the main query implementation for normal queries should throw on unrecognized operators if it doesn't already. * The dump/restore programmatic APIs in db-connect concern me. These involve returning the entire database as a string, which could exhaust memory. This impacts both utilities and also copyDatabase(). Could these APIs return and expect async iterators instead of strings? * The test "anchored regex on an indexed field uses a btree index search" runs explain on a query that's hardcoded in the test. Instead these SQL based adapters should expose a means to get the SQL for a query, so it can be directly tested. Otherwise this test proves nothing as changes to the adapter accumulate in future. * Why is this test searching for "at least 1" and not exactly 1? it('should find documents with null value', async function() { const docs = await db.collection('test').find({ value: null }).toArray(); // MongoDB matches both null and missing fields with { value: null } expect(docs.length).to.be.at.least(1); }); * What is the maximum size of a db-connect document in the postgres and sqlite adapters? * Update the copyright year in db-connect/LICENSE.md to 2025. * The db-connect README mentions: sqlite://:memory: What happens if you try to use .db('some-name') with that? I think it would be best to just not support throwaway in-memory sqlite databases because I doubt anyone would intentionally store a website in one. * do not swallow dump/restore errors on indexes * cover how to run the utilities * fix detection of source * separate sanitization for index names * regex prefix safety * pnpm --------- Co-authored-by: Thomas Boutell <boutell@vcs.trox.local>
1 parent f3501f4 commit 0d85771

66 files changed

Lines changed: 12764 additions & 201 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/monorepo.yml

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ env:
77
NODE_VERSIONS_JSON: "[20,22,24]"
88
MONGODB_VERSIONS_JSON: '["7","8"]'
99
REDIS_VERSION: "7"
10+
POSTGRES_VERSION: "16"
1011

1112
on:
1213
push:
@@ -124,7 +125,7 @@ jobs:
124125
run: |
125126
month='${{ steps.cache-month.outputs.value }}'
126127
mongo_suffix=$(jq -r '.[]' <<< '${{ env.MONGODB_VERSIONS_JSON }}' | paste -sd'-' -)
127-
echo "key=docker-images-${month}-mongo${mongo_suffix}-redis${{ env.REDIS_VERSION }}" >> "$GITHUB_OUTPUT"
128+
echo "key=docker-images-${month}-mongo${mongo_suffix}-redis${{ env.REDIS_VERSION }}-pg${{ env.POSTGRES_VERSION }}" >> "$GITHUB_OUTPUT"
128129
129130
- name: Restore docker image cache
130131
id: docker-cache
@@ -143,6 +144,8 @@ jobs:
143144
done
144145
docker pull "redis:${{ env.REDIS_VERSION }}"
145146
docker save "redis:${{ env.REDIS_VERSION }}" -o ".github/docker-cache/redis-${{ env.REDIS_VERSION }}.tar"
147+
docker pull "postgres:${{ env.POSTGRES_VERSION }}"
148+
docker save "postgres:${{ env.POSTGRES_VERSION }}" -o ".github/docker-cache/postgres-${{ env.POSTGRES_VERSION }}.tar"
146149
147150
warm-sharp-cache:
148151
name: Warm sharp/libvips cache (Node ${{ matrix.nodeVersion }})
@@ -216,7 +219,7 @@ jobs:
216219
du -sh ~/.npm/_libvips || true
217220
218221
package-tests:
219-
name: ${{ format('{0} ({1}, {2})', matrix.package, matrix.nodeVersion, matrix.needsMongo && matrix.mongodbVersion || 'n/a') }}
222+
name: ${{ format('{0} ({1}, {2}{3})', matrix.group, matrix.nodeVersion, matrix.adapter || 'n/a', matrix.needsMongo && format(' mongo {0}', matrix.mongodbVersion) || '') }}
220223
needs:
221224
- setup
222225
- shared-runtime
@@ -279,7 +282,7 @@ jobs:
279282
- name: Restore docker image cache
280283
id: docker-cache-restore
281284
uses: actions/cache/restore@v4
282-
if: matrix.needsMongo || matrix.needsRedis
285+
if: matrix.needsMongo || matrix.needsRedis || matrix.needsPostgres
283286
with:
284287
path: .github/docker-cache
285288
key: ${{ needs.shared-runtime.outputs.docker-cache-key }}
@@ -300,6 +303,14 @@ jobs:
300303
if: steps.docker-cache-restore.outputs.cache-hit != 'true' && matrix.needsRedis
301304
run: docker pull redis:${{ env.REDIS_VERSION }}
302305

306+
- name: Load cached Postgres image
307+
if: steps.docker-cache-restore.outputs.cache-hit == 'true' && matrix.needsPostgres
308+
run: docker load -i ".github/docker-cache/postgres-${{ env.POSTGRES_VERSION }}.tar"
309+
310+
- name: Pull Postgres image (cache miss)
311+
if: steps.docker-cache-restore.outputs.cache-hit != 'true' && matrix.needsPostgres
312+
run: docker pull postgres:${{ env.POSTGRES_VERSION }}
313+
303314
- name: Start MongoDB
304315
if: matrix.needsMongo
305316
run: |
@@ -358,15 +369,64 @@ jobs:
358369
docker logs redis
359370
exit 1
360371
372+
- name: Start PostgreSQL
373+
if: matrix.needsPostgres
374+
run: |
375+
docker rm -f postgres >/dev/null 2>&1 || true
376+
docker run -d \
377+
--name postgres \
378+
--publish 5432:5432 \
379+
-e POSTGRES_HOST_AUTH_METHOD=trust \
380+
--health-cmd "pg_isready -U postgres" \
381+
--health-interval 5s \
382+
--health-timeout 5s \
383+
--health-retries 12 \
384+
postgres:${{ env.POSTGRES_VERSION }}
385+
echo "Waiting for PostgreSQL to report healthy..."
386+
for attempt in $(seq 1 60); do
387+
status=$(docker inspect --format='{{.State.Health.Status}}' postgres 2>/dev/null || echo "starting")
388+
if [ "$status" = "healthy" ]; then
389+
exit 0
390+
fi
391+
if [ "$status" = "unhealthy" ]; then
392+
echo "PostgreSQL reported unhealthy" >&2
393+
docker logs postgres
394+
exit 1
395+
fi
396+
sleep 2
397+
done
398+
echo "PostgreSQL failed to become healthy in time" >&2
399+
docker logs postgres
400+
exit 1
401+
361402
- name: Install workspace dependencies
362403
run: pnpm install --frozen-lockfile
363404

364405
- name: Run package tests
365-
run: pnpm run --filter "${{ matrix.package }}" --if-present test
406+
run: |
407+
failed=0
408+
for pkg in $(echo '${{ matrix.packages }}' | jq -r '.[]'); do
409+
echo "::group::Testing $pkg"
410+
if ! pnpm run --filter "$pkg" --if-present test; then
411+
echo "::error::Tests failed for $pkg"
412+
failed=1
413+
fi
414+
echo "::endgroup::"
415+
done
416+
exit $failed
366417
env:
367418
CI: true
368419
# Yes we want import-export to test with automatic-translation
369420
TEST_WITH_PRO: "1"
421+
# Adapter selection: mongodb (default), postgres, or sqlite.
422+
# ADAPTER is used by db-connect tests, APOS_TEST_DB_PROTOCOL by apostrophe tests.
423+
ADAPTER: ${{ matrix.adapter }}
424+
APOS_TEST_DB_PROTOCOL: ${{ matrix.adapter }}
425+
PGUSER: postgres
426+
427+
- name: Stop PostgreSQL
428+
if: always() && matrix.needsPostgres
429+
run: docker rm -f postgres || true
370430

371431
- name: Stop Redis
372432
if: always() && matrix.needsRedis

.github/workflows/scripts/detect-impacted-packages.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ async function main() {
7373
package: name,
7474
directory: packages.get(name).relativeDir,
7575
requiresMongo: packages.get(name).requiresMongo !== false,
76-
requiresRedis: packages.get(name).requiresRedis === true
76+
requiresRedis: packages.get(name).requiresRedis === true,
77+
mongodbOnly: packages.get(name).mongodbOnly === true
7778
}))
7879
};
7980

@@ -130,14 +131,16 @@ async function loadPackages() {
130131
const testConfig = manifest.apostropheTestConfig || {};
131132
const requiresMongo = testConfig.requiresMongo !== false;
132133
const requiresRedis = testConfig.requiresRedis === true;
134+
const mongodbOnly = testConfig.mongodbOnly === true;
133135

134136
map.set(manifest.name, {
135137
name: manifest.name,
136138
relativeDir: path.posix.join('packages', entry.name),
137139
dependencies,
138140
hasTestScript,
139141
requiresMongo,
140-
requiresRedis
142+
requiresRedis,
143+
mongodbOnly
141144
});
142145
}));
143146

.github/workflows/scripts/expand-runtime-matrix.mjs

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
#!/usr/bin/env node
2-
// Expands the impacted package matrix with runtime permutations supplied
2+
// Expands the impacted package matrix with runtime permutations supplied
33
// via env vars.
4+
//
5+
// Packages are grouped into jobs to stay within GitHub's 256-entry matrix limit:
6+
// - "apostrophe" runs solo (the main package, benefits from its own status).
7+
// - All other database packages are grouped into an "ecosystem" job.
8+
// - mongodbOnly packages are grouped into "ecosystem-mongodb".
9+
// - Non-database packages are grouped into "standalone".
10+
//
11+
// For groups that need a database, three adapter variants are emitted:
12+
// 1. mongodb – all Node versions × all MongoDB versions
13+
// 2. postgres – latest LTS Node only, no MongoDB
14+
// 3. sqlite – latest LTS Node only, no MongoDB
15+
//
16+
// The latest LTS Node version is the highest even-numbered entry in
17+
// NODE_VERSIONS_JSON.
18+
419
import { readFile } from 'fs/promises';
520

621
const args = process.argv.slice(2);
@@ -36,25 +51,120 @@ function parseJsonArray(name, raw) {
3651
const nodeVersions = parseJsonArray('NODE_VERSIONS_JSON', process.env.NODE_VERSIONS_JSON);
3752
const mongodbVersions = parseJsonArray('MONGODB_VERSIONS_JSON', process.env.MONGODB_VERSIONS_JSON);
3853

54+
// Latest LTS = highest even-numbered Node version
55+
const latestLts = [...nodeVersions]
56+
.filter((v) => Number(v) % 2 === 0)
57+
.sort((a, b) => Number(b) - Number(a))[0];
58+
3959
const impact = JSON.parse(await readFile(impactPath, 'utf8'));
4060
const packages = impact?.matrix?.include || [];
41-
const include = [];
61+
62+
// The main apostrophe package always gets its own jobs for clear CI status.
63+
const SOLO_PACKAGES = new Set(['apostrophe']);
64+
65+
// Sort packages into groups
66+
const solo = [];
67+
const ecosystem = [];
68+
const ecosystemMongodbOnly = [];
69+
const standalone = [];
4270

4371
for (const pkg of packages) {
44-
const needsMongo = pkg.requiresMongo !== false;
45-
const needsRedis = pkg.requiresRedis === true;
46-
const mongoTargets = needsMongo ? mongodbVersions : [''];
72+
const needsDb = pkg.requiresMongo !== false;
73+
if (SOLO_PACKAGES.has(pkg.package)) {
74+
solo.push(pkg);
75+
} else if (needsDb && pkg.mongodbOnly) {
76+
ecosystemMongodbOnly.push(pkg);
77+
} else if (needsDb) {
78+
ecosystem.push(pkg);
79+
} else {
80+
standalone.push(pkg);
81+
}
82+
}
83+
84+
const include = [];
85+
86+
// Emit runtime combinations for a group of packages.
87+
function emitGroup(group, pkgs) {
88+
if (!pkgs.length) {
89+
return;
90+
}
91+
const packageNames = JSON.stringify(pkgs.map((p) => p.package));
92+
const needsRedis = pkgs.some((p) => p.requiresRedis === true);
93+
const mongodbOnly = pkgs.every((p) => p.mongodbOnly);
94+
95+
// mongodb: all Node versions × all MongoDB versions
4796
for (const nodeVersion of nodeVersions) {
48-
for (const mongodbVersion of mongoTargets) {
97+
for (const mongodbVersion of mongodbVersions) {
4998
include.push({
50-
...pkg,
99+
group,
100+
packages: packageNames,
51101
nodeVersion,
52102
mongodbVersion,
53-
needsMongo,
103+
adapter: 'mongodb',
104+
needsMongo: true,
105+
needsPostgres: false,
54106
needsRedis
55107
});
56108
}
57109
}
110+
// postgres and sqlite: latest LTS only, skip for mongodb-only groups
111+
if (!mongodbOnly) {
112+
include.push({
113+
group,
114+
packages: packageNames,
115+
nodeVersion: latestLts,
116+
mongodbVersion: '',
117+
adapter: 'postgres',
118+
needsMongo: false,
119+
needsPostgres: true,
120+
needsRedis
121+
});
122+
include.push({
123+
group,
124+
packages: packageNames,
125+
nodeVersion: latestLts,
126+
mongodbVersion: '',
127+
adapter: 'sqlite',
128+
needsMongo: false,
129+
needsPostgres: false,
130+
needsRedis
131+
});
132+
}
133+
}
134+
135+
// Emit non-database group (no adapter variants, just Node versions)
136+
function emitStandalone(group, pkgs) {
137+
if (!pkgs.length) {
138+
return;
139+
}
140+
const packageNames = JSON.stringify(pkgs.map((p) => p.package));
141+
for (const nodeVersion of nodeVersions) {
142+
include.push({
143+
group,
144+
packages: packageNames,
145+
nodeVersion,
146+
mongodbVersion: '',
147+
adapter: '',
148+
needsMongo: false,
149+
needsPostgres: false,
150+
needsRedis: false
151+
});
152+
}
153+
}
154+
155+
// Solo packages each get their own group
156+
for (const pkg of solo) {
157+
emitGroup(pkg.package, [pkg]);
158+
}
159+
160+
if (ecosystem.length) {
161+
emitGroup('ecosystem', ecosystem);
162+
}
163+
if (ecosystemMongodbOnly.length) {
164+
emitGroup('ecosystem-mongodb', ecosystemMongodbOnly);
165+
}
166+
if (standalone.length) {
167+
emitStandalone('standalone', standalone);
58168
}
59169

60170
process.stdout.write(JSON.stringify({ include }));

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ public/apos-frontend
66
.DS_Store
77
coverage/
88
.nyc_output
9+
claude-tools/logs/
10+
.claude

claude-tools/run-core-tests.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/bash
2+
# Run the apostrophe core test suite against a chosen DB adapter and log
3+
# output to claude-tools/logs/core-<adapter>.log. Usage:
4+
#
5+
# ./claude-tools/run-core-tests.sh mongodb
6+
# ./claude-tools/run-core-tests.sh postgres
7+
# ./claude-tools/run-core-tests.sh sqlite
8+
#
9+
# NEVER run multiple adapters in parallel — the test suite is not designed
10+
# for concurrent runs and the host has limited resources.
11+
12+
set -u
13+
adapter="${1:-}"
14+
if [[ -z "$adapter" ]]; then
15+
echo "usage: $0 <mongodb|postgres|sqlite>" >&2
16+
exit 2
17+
fi
18+
19+
root="$(cd "$(dirname "$0")/.." && pwd)"
20+
logdir="$root/claude-tools/logs"
21+
mkdir -p "$logdir"
22+
log="$logdir/core-$adapter.log"
23+
: > "$log"
24+
25+
echo "=== $adapter core tests ($(date -Is)) ===" | tee -a "$log"
26+
27+
cd "$root/packages/apostrophe"
28+
29+
extra=()
30+
if [[ "$adapter" == "postgres" ]]; then
31+
extra=(env PGPASSWORD=testpassword)
32+
fi
33+
34+
APOS_TEST_DB_PROTOCOL="$adapter" "${extra[@]}" npm run test:base >> "$log" 2>&1
35+
code=$?
36+
echo "=== exit=$code ===" | tee -a "$log"
37+
exit "$code"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"dev": "pnpm --parallel --recursive run dev",
66
"build": "pnpm --recursive run build",
77
"lint": "pnpm --recursive run lint",
8-
"test": "pnpm --recursive run test",
8+
"test": "APOS_TEST_DB_PROTOCOL=postgres pnpm run test:main && APOS_TEST_DB_PROTOCOL=mongodb pnpm run test:main && APOS_TEST_DB_PROTOCOL=sqlite pnpm run test:main && APOS_TEST_DB_PROTOCOL=multipostgres pnpm run test:main",
9+
"test:main": "echo \"APOS_TEST_DB_PROTOCOL IS: $APOS_TEST_DB_PROTOCOL\" && pnpm --recursive run test",
910
"eslint": "pnpm --recursive run eslint",
1011
"mocha": "pnpm --recursive run mocha",
1112
"clean": "pnpm -r exec rm -rf node_modules && rm -rf node_modules && rm pnpm-lock.yaml"
@@ -18,6 +19,7 @@
1819
},
1920
"pnpm": {
2021
"onlyBuiltDependencies": [
22+
"better-sqlite3",
2123
"sharp",
2224
"vue-demi",
2325
"@parcel/watcher",

packages/apostrophe/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ test/public/uploads
4242

4343
# vim swp files
4444
.*.sw*
45+
46+
# claude-tools log files
47+
claude-tools/**/*.log

0 commit comments

Comments
 (0)