Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
de730f5
postgres experimental WIP
boutell Feb 16, 2026
dccb36d
astonishingly, all mocha tests of apostrophe pass with this
boutell Feb 16, 2026
266841c
mocha tests pass, actual sites work
boutell Feb 17, 2026
9d67565
lint clean
boutell Feb 17, 2026
04d4edd
listDatabases support, but changes are coming
boutell Mar 7, 2026
bc7b72b
wip
boutell Mar 9, 2026
9ba57fc
dump and restore updates
boutell Mar 9, 2026
5ad412a
backpressure, adequate handling of ObjectId for our needs (becomes it…
boutell Mar 15, 2026
db1434b
mild performance optimization
boutell Mar 15, 2026
a083c25
profiling
boutell Mar 15, 2026
ddf80df
testing issue resolved
boutell Mar 16, 2026
3c7a23b
refactored to db-connect module, introduced sqlite adapter
boutell Mar 17, 2026
8b095f1
sqlite WIP
boutell Mar 17, 2026
80dd7ab
debugging
Mar 18, 2026
d08f502
programmatic API for dump/restore/copy dbs
Mar 18, 2026
374b5d8
linting, documentation
boutell Mar 21, 2026
6a5b931
MIT license
boutell Mar 21, 2026
fea05a7
text ranking is more accurate, documentation is more complete
boutell Mar 21, 2026
b67e2cd
good full text search for sqlite
boutell Mar 21, 2026
54dc9b5
updates for compatibility with the rest of the public and private mod…
boutell Mar 21, 2026
f0f45f9
requirements found by testing private modules
boutell Mar 21, 2026
f4ae5d8
Merge branch 'main' into postgres
boutell Mar 21, 2026
9c9105d
fixes from full cypress run
boutell Mar 25, 2026
ab875ef
eslint passing
boutell Mar 25, 2026
c95c9b3
restore permissions
boutell Mar 25, 2026
88e8e48
maximize atomicity
boutell Mar 26, 2026
2eadf8e
bug fixes
boutell Mar 26, 2026
9494c00
* exit properly when asset tests fail
boutell Mar 31, 2026
d3090df
ignore claude-tools in eslint
boutell Mar 31, 2026
5b3c675
postgres and sqlite-inclusive ci matrix attempt
boutell Mar 31, 2026
fe2cc10
clean up logs
boutell Mar 31, 2026
50897fd
We hit github's limit on total configurations because every package g…
boutell Mar 31, 2026
3c29e2a
hardened the asset tests, made them less timing sensitive, fixed a ba…
boutell Mar 31, 2026
1370762
fix a root cause of asset test instability
boutell Mar 31, 2026
4b1716e
log mess
boutell Mar 31, 2026
2fc2224
implemented missing $size operator
boutell Mar 31, 2026
c260229
test compatibility
boutell Mar 31, 2026
7408e4b
advanced permission uses regex in $in
boutell Apr 2, 2026
f0bfb0e
regex in $in
boutell Apr 2, 2026
1f1e6b3
.db() should not make false promises in plain postgres mode, it shoul…
boutell Apr 12, 2026
c198a01
ability to specify a default adapter
boutell Apr 12, 2026
14dffe3
obsolete file
boutell Apr 12, 2026
7e60e35
put escapeHost back where it belongs
boutell Apr 12, 2026
bef7978
dead code removal, test cleanup
boutell Apr 12, 2026
8cb3c5a
emulate-mongo-3-driver only needed in db-connect
boutell Apr 12, 2026
ec9ec17
no claude logs in repo (tools are welcome)
boutell Apr 12, 2026
0283d85
* shared aggregation implementation, other shared things
boutell Apr 12, 2026
3a8901d
vanilla postgres should not attempt to use .db() with alternate names…
boutell Apr 12, 2026
061136a
documentation corrections
boutell Apr 12, 2026
9c91f9f
documentation errors
boutell Apr 12, 2026
4e11aad
listDatabases and documentation corrections
boutell Apr 13, 2026
615399f
Merge branch 'main' into postgres
boutell Apr 13, 2026
f492eb4
more edge cases revealed by latest work from Miro
boutell Apr 13, 2026
c7afe8d
anchored prefix regexps are optimized
boutell Apr 14, 2026
7aa5fd1
* matchesQuery in the aggregation cursor implementation doesn't thr…
boutell Apr 14, 2026
cfdd3c6
do not swallow dump/restore errors on indexes
boutell Apr 14, 2026
a9338c4
cover how to run the utilities
boutell Apr 14, 2026
c863da6
fix detection of source
boutell Apr 14, 2026
6971100
separate sanitization for index names
boutell Apr 15, 2026
3885742
Merge branch 'main' into postgres
boutell Apr 15, 2026
8bc9d3f
regex prefix safety
boutell Apr 16, 2026
9af8358
pnpm
boutell Apr 20, 2026
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"sharp",
"vue-demi",
"@parcel/watcher",
Expand Down
160 changes: 160 additions & 0 deletions packages/apostrophe/modules/@apostrophecms/db/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# @apostrophecms/db - Universal Database Adapter

## Project Overview

A universal database adapter for ApostropheCMS that provides a MongoDB-compatible interface with pluggable backends. Currently supports MongoDB (passthrough) and PostgreSQL (full implementation).

**Design Philosophy:**
- Expose only operations actually used by ApostropheCMS (not the full MongoDB API)
- Make backends easy to write by keeping the interface minimal
- Bias toward escaping inputs rather than rejecting them (except where no safe representation exists)
- All `createIndex` calls happen at startup, so no runtime schema introspection needed

## File Structure

```
db/
├── index.js # Main entry point, exports { mongodb, postgres }
├── adapters/
│ ├── mongodb.js # Thin wrapper around native MongoDB driver
│ └── postgres.js # Full PostgreSQL implementation (~1500 lines)
├── test/
│ ├── adapter.test.js # Comprehensive test suite (106 MongoDB / 125 PostgreSQL tests)
│ └── security.test.js # SQL injection prevention tests
├── package.json
└── CLAUDE.md
```

## Connection API

Both adapters use URI as the first argument:

```javascript
const { mongodb, postgres } = require('@apostrophecms/db');

// MongoDB
const client = await mongodb.connect('mongodb://localhost:27017/mydb');

// PostgreSQL
const client = await postgres.connect('postgres://user:pass@localhost:5432/mydb');

// Get database reference (uses URI's database if no argument)
const db = client.db();

// Or switch databases
const otherDb = client.db('other-database');

await client.close();
```

Each adapter exports `protocols` array for URI scheme matching:
- MongoDB: `['mongodb', 'mongodb+srv']`
- PostgreSQL: `['postgres', 'postgresql']`

## Supported Operations

### Collection Methods
- `insertOne`, `insertMany`
- `find` (returns cursor), `findOne`
- `updateOne`, `updateMany`, `replaceOne`
- `deleteOne`, `deleteMany`
- `countDocuments`, `distinct`
- `aggregate` (with `$match`, `$sort`, `$limit`, `$skip`, `$project`, `$unwind`)
- `bulkWrite`
- `findOneAndUpdate`
- `createIndex`, `dropIndex`, `indexes`
- `drop`, `rename`

### Query Operators
`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$and`, `$or`, `$not`, `$exists`, `$regex`, `$all`

### Update Operators
`$set`, `$unset`, `$inc`, `$push`, `$pull`, `$addToSet`, `$currentDate`

### Cursor Methods
`sort`, `limit`, `skip`, `project`, `toArray`, `count`, `clone`

## PostgreSQL Implementation Details

### Storage Model
- Documents stored as JSONB in `data` column
- `_id` extracted to TEXT primary key column
- Table names: `{dbname}_{collectionname}` (hyphens converted to underscores)

### Date Handling
Dates are serialized as `{ $date: "ISO8601 string" }` wrapper objects because:
1. JSON.stringify calls `toJSON()` on Dates before any replacer sees them
2. Need to distinguish dates from strings for proper deserialization
3. ISO 8601 strings sort correctly as text (important for indexes)

### Security Approach
- **Table/index names**: Validated against `^[a-zA-Z_][a-zA-Z0-9_]*$` (PostgreSQL limitation)
- **Field names in JSONB**: Escaped with `escapeString()` (single quotes doubled)
- **Values**: Always parameterized (`$1`, `$2`, etc.)
- **LIMIT/OFFSET**: Validated as non-negative integers

### Query Building
`buildWhereClause` and `buildOperatorClause` **mutate** the `params` array by pushing values. The returned SQL contains positional placeholders referencing array indices. This is documented in the code.

### Typed Indexes for Range Queries

PostgreSQL requires explicit typing for numeric indexes. The `type` option enables this:

```javascript
// Text index (default) - for $eq, $in, $regex
await collection.createIndex({ slug: 1 });

// Numeric index - for $gt/$lt on numbers
await collection.createIndex({ price: 1 }, { type: 'number' });
// Creates: ((data->>'price')::numeric)

// Date index - for $gt/$lt on dates
await collection.createIndex({ createdAt: 1 }, { type: 'date' });
// Creates: (data->'createdAt'->>'$date') - text, since ISO sorts correctly
```

Without `type`, range queries won't use the index (they still work, just slower).

### Sparse Indexes
Implemented via PostgreSQL partial indexes:
```javascript
await collection.createIndex({ field: 1 }, { sparse: true });
// Creates: ... WHERE data->'field' IS NOT NULL
```

## Testing

```bash
# Run all tests with MongoDB
npm run test:mongodb

# Run all tests with PostgreSQL
npm run test:postgres

# Run both
npm test
```

**Test Prerequisites:**
- MongoDB running on localhost:27017
- PostgreSQL running on localhost:5432 with database `dbtest_adapter`
- Set `PGUSER`/`PGPASSWORD` env vars if needed

## Known Limitations / Future Work

1. **Aggregation**: Only basic stages implemented (`$match`, `$sort`, `$limit`, `$skip`, `$project`, `$unwind`)
2. **Text search**: Basic GIN index support, not full MongoDB text search semantics
3. **Transactions**: No (not an ApostropheCMS requirement)
4. **Change streams**: No (not an ApostropheCMS requirement)

## Common Gotchas

1. **Date comparisons**: Dates are stored as `{$date: "..."}` wrapper, so raw JSONB queries won't work - use the adapter's query interface

2. **Nested field paths**: Use dot notation (`user.profile.name`) - works in queries, updates, indexes, and projections

3. **Index type matters**: If you're doing `{ price: { $gt: 100 } }` queries, you need `{ type: 'number' }` on the index for PostgreSQL to use it

4. **Database switching**: In PostgreSQL, different "databases" are actually table prefixes in the same PostgreSQL database. This is intentional for simpler connection management.

5. **Collection name validation**: Hyphens are converted to underscores internally. Names with special characters beyond that will be rejected.
54 changes: 37 additions & 17 deletions packages/apostrophe/modules/@apostrophecms/db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
//
// ### `uri`
//
// The MongoDB connection URI. See the [MongoDB URI documentation](https://docs.mongodb.com/manual/reference/connection-string/).
// The databse connection URI. See the [MongoDB URI documentation](https://docs.mongodb.com/manual/reference/connection-string/)
// and the postgres documentation.
//
// ### `connect`
//
// If present, this object is passed on as options to MongoDB's "connect"
// If present, this object is passed on as options to the database adapters "connect"
// method, along with the uri. See the [MongoDB connect settings documentation](http://mongodb.github.io/node-mongodb-native/2.2/reference/connecting/connection-settings/).
//
// By default, Apostrophe sets options to retry lost connections forever,
Expand All @@ -20,9 +21,16 @@
//
// ### `client`
//
// An existing MongoDB connection (MongoClient) object. If present, it is used
// An existing MongoDB-compatible client object. If present, it is used
// and `uri`, `host`, `connect`, etc. are ignored.
//
// ### `adapters`
//
// An array of adapters, each of which must provide `name`, `connect(uri, options)`,
// and `protocols` properties. `name` may be used to override a core adapter,
// such as `postgres` or `mongodb`. `connect` must resolve to a client object
// supporting a sufficient subset of the mongodb API.
//
// ### `versionCheck`
//
// If `true`, check to make sure the database does not belong to an
Expand All @@ -49,15 +57,15 @@
// in your project. However you may find it easier to just use the
// `client` option.

const mongodbConnect = require('../../../lib/mongodb-connect');
const escapeHost = require('../../../lib/escape-host');
const dbConnect = require('@apostrophecms/db-connect');
const escapeHost = require('@apostrophecms/db-connect').escapeHost;

module.exports = {
options: {
versionCheck: true
},
async init(self) {
await self.connectToMongo();
await self.connectToDb();
await self.versionCheck();
},
handlers(self) {
Expand All @@ -81,14 +89,12 @@ module.exports = {
},
methods(self) {
return {
// Open the database connection. Always uses MongoClient with its
// sensible defaults. Builds a URI if necessary, so we can call it
// in a consistent way.
//
// One default we override: if the connection is lost, we keep
// attempting to reconnect forever. This is the most sensible behavior
// for a persistent process that requires MongoDB in order to operate.
async connectToMongo() {
// Connect to the database and sets self.apos.dbClient
// and self.apos.db. Builds a mongodb URI by default,
// accepting host, port, user, password and name options
// if present. More typically a URI is specified via
// APOS_DB_URI, or via APOS_MONGODB_URI for bc.
async connectToDb() {
if (self.options.client) {
// Reuse a single client connection http://mongodb.github.io/node-mongodb-native/2.2/api/Db.html#db
self.apos.dbClient = self.options.client;
Expand All @@ -97,8 +103,9 @@ module.exports = {
return;
}
let uri = 'mongodb://';
if (process.env.APOS_MONGODB_URI) {
uri = process.env.APOS_MONGODB_URI;
const viaEnv = process.env.APOS_DB_URI || process.env.APOS_MONGODB_URI;
if (viaEnv) {
uri = viaEnv;
} else if (self.options.uri) {
uri = self.options.uri;
} else {
Expand All @@ -117,11 +124,24 @@ module.exports = {
uri += escapeHost(self.options.host) + ':' + self.options.port + '/' + self.options.name;
}

self.apos.dbClient = await mongodbConnect(uri, self.options.connect);
self.apos.dbClient = await dbConnect(uri, {
...self.options.connect,
adapters: self.options.adapters
});
self.uri = uri;
// Automatically uses the db name in the connection string
self.apos.db = self.apos.dbClient.db();
},
// Connect to a database using the appropriate adapter based on the URI protocol.
// Returns a client object compatible with the MongoDB driver interface.
// This method has no side effects — it does not set apos.db or apos.dbClient.
// It can be used to make temporary connections, e.g. for dropping a test database.
async connectToAdapter(uri, options) {
return dbConnect(uri, {
...options,
adapters: self.options.adapters
});
},
async versionCheck() {
if (!self.options.versionCheck) {
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/apostrophe/modules/@apostrophecms/http/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const _ = require('lodash');
const qs = require('qs');
const fetch = require('node-fetch');
const tough = require('tough-cookie');
const escapeHost = require('../../../lib/escape-host');
const escapeHost = require('@apostrophecms/db-connect').escapeHost;
const util = require('util');

module.exports = {
Expand Down
16 changes: 9 additions & 7 deletions packages/apostrophe/modules/@apostrophecms/job/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ module.exports = {
},
setTotal (n) {
total = n;
return self.setTotal(job, n);
const result = self.setTotal(job, n);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated fix for clean shutdown

promises.push(result);
return result;
},
setResults (_results) {
results = _results;
Expand Down Expand Up @@ -412,12 +414,12 @@ module.exports = {
//
// No promise is returned as this method just updates
// the job tracking information in the background.
setTotal(job, total) {
self.db.updateOne({ _id: job._id }, { $set: { total } }, function (err) {
if (err) {
self.apos.util.error(err);
}
});
async setTotal(job, total) {
try {
await self.db.updateOne({ _id: job._id }, { $set: { total } });
} catch (err) {
self.apos.util.error(err);
}
},
// Mark the given job as ended. If `success`
// is true the job is reported as an overall
Expand Down
9 changes: 7 additions & 2 deletions packages/apostrophe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"pretest": "npm run lint",
"test": "npm run test:base && npm run test:missing && npm run test:assets && npm run test:esm",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asset tests are now a good citizen, don't need to be broken out separately anymore

"test": "npm run test:base && npm run test:missing && npm run test:assets && npm run test:esm && npm run test:db",
"test:base": "nyc mocha -t 10000 --ignore=test/assets.js",
"test:missing": "nyc mocha -t 10000 test/add-missing-schema-fields-project/test.js",
"test:assets": "nyc mocha -t 10000 test/assets.js",
Expand All @@ -15,7 +15,10 @@
"i18n": "node scripts/lint-i18n",
"stylelint": "stylelint modules/**/*.{scss,vue}",
"lint": "npm run eslint && npm run i18n && npm run stylelint",
"mocha": "mocha"
"mocha": "mocha",
"test:db": "cd ../db-connect && npm test",
"test:db:mongodb": "cd ../db-connect && ADAPTER=mongodb npx mocha test/**/*.test.js --timeout 30000",
"test:db:postgres": "cd ../db-connect && ADAPTER=postgres npx mocha test/**/*.test.js --timeout 30000"
},
"repository": {
"type": "git",
Expand All @@ -38,6 +41,7 @@
"author": "Apostrophe Technologies, Inc.",
"license": "MIT",
"dependencies": {
"@apostrophecms/db-connect": "workspace:^",
"@apostrophecms/emulate-mongo-3-driver": "workspace:^",
"@apostrophecms/vue-material-design-icons": "^1.0.0",
"@ctrl/tinycolor": "^4.1.0",
Expand Down Expand Up @@ -141,6 +145,7 @@
"xregexp": "^2.0.0"
},
"devDependencies": {
"chai": "^4.3.10",
"eslint": "^9.39.1",
"eslint-config-apostrophe": "workspace:^",
"form-data": "^4.0.4",
Expand Down
Empty file modified packages/apostrophe/scripts/find-heavy-npm-modules
100755 → 100644
Empty file.
Loading
Loading