Skip to content

Commit 1588db3

Browse files
committed
💥 Comply with sharedb spec
This is a **BREAKING** change that: - adds tests against the [upstream `sharedb` DB test suite][1] - adds a CI build for the tests against current Node.js and Postgres versions - breaks the API to conform to the upstream tests, including adding metadata support The breaks are: - Dropping non-null constraints on `snapshots.doc_type` and `snapshots.data` - Adding a new `snapshots.metadata` `json` column - Respecting `options.metadata` and `fields.$submit`, which were previously ignored on `getOps()`, and useless on `getSnapshot()` (which didn't store metadata) - `snapshot.m` is now `undefined` if not present, or `null` if unrequested (inline with the spec) On top of this it also makes some bugfixes to conform to the spec: - Ignore unique key validations when committing, since this may happen during concurrent commits - `JSON.stringify()` JSON fields, which [break][2] if passed a raw array - Default `from = 0` if unset in `getOps()` [1]: https://github.com/share/sharedb/blob/7abe65049add9b58e1df638aa34e7ca2c0a1fcfa/test/db.js#L25 [2]: brianc/node-postgres#442
1 parent 4b5b537 commit 1588db3

File tree

8 files changed

+2350
-354
lines changed

8 files changed

+2350
-354
lines changed

‎.github/workflows/test.yml

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- setup-ci # TODO: Remove
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
test:
14+
name: Node.js ${{ matrix.node }} + PostgreSQL ${{ matrix.postgres }}
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
node:
20+
- 16
21+
- 18
22+
- 20
23+
postgres:
24+
- 13
25+
- 14
26+
- 15
27+
- 16
28+
services:
29+
postgres:
30+
image: postgres:${{ matrix.postgres }}
31+
env:
32+
POSTGRES_PASSWORD: ''
33+
options: >-
34+
--health-cmd pg_isready
35+
--health-interval 10s
36+
--health-timeout 5s
37+
--health-retries 5
38+
ports:
39+
- 5432:5432
40+
timeout-minutes: 10
41+
steps:
42+
- uses: actions/checkout@v4
43+
- uses: actions/setup-node@v4
44+
with:
45+
node-version: ${{ matrix.node }}
46+
- name: Install
47+
run: npm install
48+
- name: Test
49+
run: npm test

‎.mocharc.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
timeout: 5_000,
3+
file: './test/setup.js',
4+
spec: '**/*.spec.js',
5+
};

‎index.js

+86-105
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
var DB = require('sharedb').DB;
22
var pg = require('pg');
33

4+
const PG_UNIQUE_VIOLATION = 23505;
5+
46
// Postgres-backed ShareDB database
57

68
function PostgresDB(options) {
@@ -9,24 +11,33 @@ function PostgresDB(options) {
911

1012
this.closed = false;
1113

12-
this.pg_config = options;
13-
this.pool = new pg.Pool(options);
14+
this._pool = new pg.Pool(options);
1415
};
1516
module.exports = PostgresDB;
1617

1718
PostgresDB.prototype = Object.create(DB.prototype);
1819

19-
PostgresDB.prototype.close = function(callback) {
20-
this.closed = true;
21-
this.pool.end();
22-
23-
if (callback) callback();
20+
PostgresDB.prototype.close = async function(callback) {
21+
let error;
22+
try {
23+
if (!this.closed) {
24+
this.closed = true;
25+
await this._pool.end();
26+
}
27+
} catch (err) {
28+
error = err;
29+
}
30+
31+
// FIXME: Don't swallow errors. Emit 'error' event?
32+
if (callback) callback(error);
2433
};
2534

2635

2736
// Persists an op and snapshot if it is for the next version. Calls back with
2837
// callback(err, succeeded)
29-
PostgresDB.prototype.commit = function(collection, id, op, snapshot, options, callback) {
38+
PostgresDB.prototype.commit = async function(collection, id, op, snapshot, options, callback) {
39+
let client;
40+
try {
3041
/*
3142
* op: CreateOp {
3243
* src: '24545654654646',
@@ -37,34 +48,29 @@ PostgresDB.prototype.commit = function(collection, id, op, snapshot, options, ca
3748
* }
3849
* snapshot: PostgresSnapshot
3950
*/
40-
this.pool.connect((err, client, done) => {
41-
if (err) {
42-
done(client);
43-
callback(err);
44-
return;
45-
}
51+
client = await this._pool.connect();
4652
/*
47-
* This query uses common table expression to upsert the snapshot table
53+
* This query uses common table expression to upsert the snapshot table
4854
* (iff the new version is exactly 1 more than the latest table or if
4955
* the document id does not exists)
5056
*
51-
* It will then insert into the ops table if it is exactly 1 more than the
57+
* It will then insert into the ops table if it is exactly 1 more than the
5258
* latest table or it the first operation and iff the previous insert into
5359
* the snapshot table is successful.
5460
*
5561
* This result of this query the version of the newly inserted operation
5662
* If either the ops or the snapshot insert fails then 0 rows are returned
5763
*
58-
* If 0 zeros are return then the callback must return false
64+
* If 0 zeros are return then the callback must return false
5965
*
6066
* Casting is required as postgres thinks that collection and doc_id are
61-
* not varchar
62-
*/
67+
* not varchar
68+
*/
6369
const query = {
6470
name: 'sdb-commit-op-and-snap',
6571
text: `WITH snapshot_id AS (
66-
INSERT INTO snapshots (collection, doc_id, doc_type, version, data)
67-
SELECT $1::varchar collection, $2::varchar doc_id, $4 doc_type, $3 v, $5 d
72+
INSERT INTO snapshots (collection, doc_id, version, doc_type, data, metadata)
73+
SELECT $1::varchar collection, $2::varchar doc_id, $3 v, $4 doc_type, $5 d, $6 m
6874
WHERE $3 = (
6975
SELECT version+1 v
7076
FROM snapshots
@@ -76,11 +82,11 @@ PostgresDB.prototype.commit = function(collection, id, op, snapshot, options, ca
7682
WHERE collection = $1 AND doc_id = $2
7783
FOR UPDATE
7884
)
79-
ON CONFLICT (collection, doc_id) DO UPDATE SET version = $3, data = $5, doc_type = $4
85+
ON CONFLICT (collection, doc_id) DO UPDATE SET version = $3, data = $5, doc_type = $4, metadata = $5
8086
RETURNING version
8187
)
8288
INSERT INTO ops (collection, doc_id, version, operation)
83-
SELECT $1::varchar collection, $2::varchar doc_id, $3 v, $6 operation
89+
SELECT $1::varchar collection, $2::varchar doc_id, $3 v, $7 operation
8490
WHERE (
8591
$3 = (
8692
SELECT max(version)+1
@@ -93,66 +99,53 @@ WHERE (
9399
)
94100
) AND EXISTS (SELECT 1 FROM snapshot_id)
95101
RETURNING version`,
96-
values: [collection,id,snapshot.v, snapshot.type, snapshot.data,op]
102+
values: [collection, id, snapshot.v, snapshot.type, JSON.stringify(snapshot.data), JSON.stringify(snapshot.m), JSON.stringify(op)]
97103
}
98-
client.query(query, (err, res) => {
99-
if (err) {
100-
callback(err)
101-
} else if(res.rows.length === 0) {
102-
done(client);
103-
callback(null,false)
104-
}
105-
else {
106-
done(client);
107-
callback(null,true)
108-
}
109-
})
110-
111-
})
104+
const result = await client.query(query);
105+
const success = result.rowCount > 0;
106+
callback(null, success);
107+
} catch (error) {
108+
// Return non-success instead of duplicate key error, since this is
109+
// expected to occur during simultaneous creates on the same id
110+
if (error.code === PG_UNIQUE_VIOLATION) callback(null, false);
111+
else callback(error);
112+
} finally {
113+
if (client) client.release(true);
114+
}
112115
};
113116

114117
// Get the named document from the database. The callback is called with (err,
115118
// snapshot). A snapshot with a version of zero is returned if the docuemnt
116119
// has never been created in the database.
117-
PostgresDB.prototype.getSnapshot = function(collection, id, fields, options, callback) {
118-
this.pool.connect(function(err, client, done) {
119-
if (err) {
120-
done(client);
121-
callback(err);
122-
return;
123-
}
124-
client.query(
125-
'SELECT version, data, doc_type FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1',
120+
PostgresDB.prototype.getSnapshot = async function(collection, id, fields, options, callback) {
121+
fields ||= {};
122+
options ||= {};
123+
const wantsMetadata = fields.$submit || options.metadata;
124+
let client;
125+
try {
126+
client = await this._pool.connect();
127+
const result = await client.query(
128+
'SELECT version, data, doc_type, metadata FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1',
126129
[collection, id],
127-
function(err, res) {
128-
done();
129-
if (err) {
130-
callback(err);
131-
return;
132-
}
133-
if (res.rows.length) {
134-
var row = res.rows[0]
135-
var snapshot = new PostgresSnapshot(
136-
id,
137-
row.version,
138-
row.doc_type,
139-
row.data,
140-
undefined // TODO: metadata
141-
)
142-
callback(null, snapshot);
143-
} else {
144-
var snapshot = new PostgresSnapshot(
145-
id,
146-
0,
147-
null,
148-
undefined,
149-
undefined
150-
)
151-
callback(null, snapshot);
152-
}
153-
}
154-
)
155-
})
130+
);
131+
132+
var row = result.rows[0]
133+
const snapshot = {
134+
id,
135+
v: row?.version || 0,
136+
type: row?.doc_type || null,
137+
data: row?.data || undefined,
138+
m: wantsMetadata ?
139+
// Postgres returns null but ShareDB expects undefined
140+
(row?.metadata || undefined) :
141+
null,
142+
};
143+
callback(null, snapshot);
144+
} catch (error) {
145+
callback(error);
146+
} finally {
147+
client.release(true);
148+
}
156149
};
157150

158151
// Get operations between [from, to) noninclusively. (Ie, the range should
@@ -164,37 +157,25 @@ PostgresDB.prototype.getSnapshot = function(collection, id, fields, options, cal
164157
// The version will be inferred from the parameters if it is missing.
165158
//
166159
// Callback should be called as callback(error, [list of ops]);
167-
PostgresDB.prototype.getOps = function(collection, id, from, to, options, callback) {
168-
this.pool.connect(function(err, client, done) {
169-
if (err) {
170-
done(client);
171-
callback(err);
172-
return;
173-
}
174-
160+
PostgresDB.prototype.getOps = async function(collection, id, from, to, options, callback) {
161+
from ||= 0;
162+
options ||= {};
163+
const wantsMetadata = options.metadata;
164+
let client;
165+
try {
166+
client = await this._pool.connect();
175167
var cmd = 'SELECT version, operation FROM ops WHERE collection = $1 AND doc_id = $2 AND version > $3 ';
176168
var params = [collection, id, from];
177169
if(to || to == 0) { cmd += ' AND version <= $4'; params.push(to)}
178170
cmd += ' order by version';
179-
client.query( cmd, params,
180-
function(err, res) {
181-
done();
182-
if (err) {
183-
callback(err);
184-
return;
185-
}
186-
callback(null, res.rows.map(function(row) {
187-
return row.operation;
188-
}));
189-
}
190-
)
191-
})
171+
const result = await client.query(cmd, params);
172+
callback(null, result.rows.map(({operation}) => {
173+
if (!wantsMetadata) delete operation.m;
174+
return operation;
175+
}));
176+
} catch (error) {
177+
callback(error);
178+
} finally {
179+
client.release(true);
180+
}
192181
};
193-
194-
function PostgresSnapshot(id, version, type, data, meta) {
195-
this.id = id;
196-
this.v = version;
197-
this.type = type;
198-
this.data = data;
199-
this.m = meta;
200-
}

‎index.spec.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const PostgresDB = require('.');
2+
const {Pool} = require('pg');
3+
const fs = require('node:fs');
4+
5+
const DB_NAME = 'sharedbtest';
6+
7+
function create(callback) {
8+
var db = new PostgresDB({database: DB_NAME});
9+
callback(null, db);
10+
};
11+
12+
describe('PostgresDB', function() {
13+
let pool;
14+
let client;
15+
16+
beforeEach(async () => {
17+
pool = new Pool({database: 'postgres'});
18+
client = await pool.connect();
19+
await client.query(`DROP DATABASE IF EXISTS ${DB_NAME}`);
20+
await client.query(`CREATE DATABASE ${DB_NAME}`);
21+
22+
const testPool = new Pool({database: DB_NAME});
23+
const testClient = await testPool.connect();
24+
const structure = fs.readFileSync('./structure.sql', 'utf8');
25+
await testClient.query(structure);
26+
await testClient.release(true);
27+
await testPool.end();
28+
});
29+
30+
afterEach(async function() {
31+
await client.query(`DROP DATABASE IF EXISTS ${DB_NAME}`);
32+
await client.release(true);
33+
await pool.end();
34+
});
35+
36+
require('sharedb/test/db')({create: create});
37+
});

0 commit comments

Comments
 (0)