├── dynamo ├── .gitignore ├── test ├── mocha.opts ├── teardown.js ├── tests │ ├── Item.js │ ├── Database.js │ └── Table.js └── setup.js ├── .travis.yml ├── lib ├── Attributes.js ├── index.js ├── Credentials.js ├── Key.js ├── Value.js ├── ProvisionedThroughput.js ├── KeySchema.js ├── Account.js ├── Scan.js ├── Item.js ├── Request.js ├── Predicates.js ├── Query.js ├── Batch.js ├── Update.js ├── Database.js ├── Session.js └── Table.js ├── package.json ├── LICENSE.txt └── README.md /dynamo: -------------------------------------------------------------------------------- 1 | lib -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dynamo.wiki 3 | *.log -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --bail 2 | --timeout 100s 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.8 4 | env: 5 | global: 6 | - secure: "eYjFDPhy1lkcVy9/0oVysjpcJ6owui7m1Gyaq/6luBA3XhRDCU0HQkX5irw8\nZNk2x3W0mKDUH8AynfMCmGZFaefFOTEv+AJYL5W3DGt9OzZtJgMrYS5o5pjj\nKVeBQMM3vm+z798yz1dOxbwP193ApQicuhFGi/r2969H368xQrs=" 7 | - secure: "rGo+XgYKV9usM3iFGsH6wsImXlRIrbICEj2ykEEMZkaw9PH/lxs86kw5h6ei\nCSKwDtY4zXDyLu+4VJmwEzCnYVLVxEfRBZPWdjaJwqDgPXfICshfT0hM8BrC\nw6oGP2IZsWWNaub83LJ25tUUigUrV98oatO3VlxDSTBGSlp66Rw=" 8 | -------------------------------------------------------------------------------- /lib/Attributes.js: -------------------------------------------------------------------------------- 1 | var Value = require("./Value") 2 | 3 | function Attributes(attrs) { 4 | var obj = {} 5 | 6 | Object.keys(attrs).forEach(function(key) { 7 | obj[key] = Value(attrs[key]) 8 | }) 9 | 10 | return obj 11 | } 12 | 13 | Attributes.prototype = { 14 | parse: function(data) { 15 | var obj = {} 16 | 17 | Object.keys(data).forEach(function(key) { 18 | obj[key] = Value.prototype.parse(data[key]) 19 | }) 20 | 21 | return obj 22 | } 23 | } 24 | 25 | module.exports = Attributes -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var Account = exports.Account = require("./Account") 2 | 3 | exports.Credentials = require("./Credentials") 4 | exports.Database = require("./Database") 5 | exports.Request = require("./Request") 6 | exports.Session = require("./Session") 7 | 8 | exports.createClient = function(credentials) { 9 | if (!credentials) { 10 | credentials = { 11 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 12 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY 13 | } 14 | } 15 | 16 | return new Account(credentials) 17 | } 18 | -------------------------------------------------------------------------------- /lib/Credentials.js: -------------------------------------------------------------------------------- 1 | var crypto = require("crypto") 2 | 3 | function Credentials(attrs) { 4 | var secretAccessKey = attrs.secretAccessKey 5 | 6 | this.accessKeyId = attrs.accessKeyId 7 | 8 | if (!secretAccessKey) { 9 | throw new Error("No secret access key provided.") 10 | } 11 | 12 | if (!this.accessKeyId) { 13 | throw new Error("No access key id provided.") 14 | } 15 | 16 | this.sign = function(data) { 17 | return crypto 18 | .createHmac("sha256", secretAccessKey) 19 | .update(data) 20 | .digest("base64") 21 | } 22 | } 23 | 24 | module.exports = Credentials -------------------------------------------------------------------------------- /lib/Key.js: -------------------------------------------------------------------------------- 1 | var Value = require("./Value") 2 | 3 | function Key(attrs) { 4 | if (!(this instanceof Key)) return new Key(attrs) 5 | 6 | function setSchema(value, pos) { 7 | if (pos > 1) throw new Error("More than two key elements specified.") 8 | 9 | this[["HashKeyElement", "RangeKeyElement"][pos]] = value 10 | } 11 | 12 | if (Array.isArray(attrs)) { 13 | attrs.forEach(function(element, pos) { 14 | setSchema.call(this, Value(element[1]), pos) 15 | }, this) 16 | } else { 17 | Object.keys(attrs).forEach(function(name, pos) { 18 | setSchema.call(this, Value(attrs[name]), pos) 19 | }, this) 20 | } 21 | } 22 | 23 | module.exports = Key 24 | -------------------------------------------------------------------------------- /test/teardown.js: -------------------------------------------------------------------------------- 1 | var should = require("should") 2 | , dynamo = require("../") 3 | , client = dynamo.createClient() 4 | , db = client.get("us-east-1") 5 | 6 | describe("teardown -", function() { 7 | it("delete existing test tables", function(done) { 8 | db.remove("DYNAMO_TEST_TABLE_1", function() { 9 | db.remove("DYNAMO_TEST_TABLE_2", function() { 10 | db.remove("DYNAMO_TEST_TABLE_3", function() { 11 | done() 12 | }) 13 | }) 14 | }) 15 | }) 16 | 17 | it("make sure no test tables exist", function(done) { 18 | db.get("DYNAMO_TEST_TABLE_1").watch(function() { 19 | db.get("DYNAMO_TEST_TABLE_2").watch(function() { 20 | db.get("DYNAMO_TEST_TABLE_3").watch(function() { 21 | done() 22 | }) 23 | }) 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Jed Schmidt (http://jed.is)", 3 | "name": "dynamo", 4 | "description": "DynamoDB client for node.js", 5 | "version": "0.2.14", 6 | "homepage": "https://github.com/jed/dynamo", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/jed/dynamo.git" 10 | }, 11 | "keywords": [ 12 | "amazon", 13 | "aws", 14 | "DynamoDB", 15 | "dynamo", 16 | "nosql", 17 | "database" 18 | ], 19 | "main": "./lib", 20 | "scripts": { 21 | "pretest": "mocha ./test/setup.js", 22 | "test": "mocha ./test/tests/*", 23 | "posttest": "mocha ./test/teardown.js" 24 | }, 25 | "engines": { 26 | "node": ">=0.6.0" 27 | }, 28 | "devDependencies": { 29 | "mocha": "*", 30 | "should": "*" 31 | }, 32 | "dependencies": {}, 33 | "optionalDependencies": {} 34 | } 35 | -------------------------------------------------------------------------------- /lib/Value.js: -------------------------------------------------------------------------------- 1 | function Value(value) { 2 | switch (typeof value) { 3 | case "number": return {N: String(value)} 4 | case "string": return {S: value} 5 | } 6 | 7 | if (value) switch (typeof value[0]) { 8 | case "number": return {NN: value.map(String)} 9 | case "string": return {SS: value} 10 | } 11 | 12 | throw new Error("Invalid key value type.") 13 | } 14 | 15 | Value.prototype = { 16 | parse: function(data) { 17 | var name = Object.keys(data)[0] 18 | , value = data[name] 19 | 20 | switch (name) { 21 | case "S": 22 | case "SS": 23 | return value 24 | 25 | case "N": 26 | return Number(value) 27 | 28 | case "NS": 29 | return value.map(Number) 30 | 31 | default: 32 | throw new Error("Invalid data type: " + name) 33 | } 34 | } 35 | } 36 | 37 | module.exports = Value 38 | -------------------------------------------------------------------------------- /test/tests/Item.js: -------------------------------------------------------------------------------- 1 | var should = require("should") 2 | , dynamo = require("../../") 3 | , client = dynamo.createClient() 4 | , db = client.get("us-east-1") 5 | 6 | describe("Item", function() { 7 | describe("#fetch", function() { 8 | it("should return the item's attributes", function(done) { 9 | var item = db.get("DYNAMO_TEST_TABLE_1").get({id: "0"}) 10 | 11 | item.fetch(function(err, data) { 12 | should.not.exist(err) 13 | should.exist(data) 14 | 15 | data.should.have.property("id") 16 | data.should.have.property("favoriteColors") 17 | data.should.have.property("name") 18 | 19 | done() 20 | }) 21 | }) 22 | }) 23 | 24 | describe("#destroy", function() { 25 | it("should delete the item", function(done) { 26 | var item = db.get("DYNAMO_TEST_TABLE_1").get({id: "0"}) 27 | 28 | item.destroy(function(err, data) { 29 | should.not.exist(err) 30 | 31 | done() 32 | }) 33 | }) 34 | }) 35 | 36 | }) 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Jed Schmidt, http://jed.is/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/ProvisionedThroughput.js: -------------------------------------------------------------------------------- 1 | function ProvisionedThroughput(attrs, table) { 2 | Object.defineProperty( 3 | this, "table", 4 | {value: table, enumerable: false} 5 | ) 6 | 7 | this.ReadCapacityUnits = attrs.read 8 | this.WriteCapacityUnits = attrs.write 9 | } 10 | 11 | ProvisionedThroughput.prototype = { 12 | update: function(attrs, cb) { 13 | var self = this 14 | 15 | this.ReadCapacityUnits = attrs.read 16 | this.WriteCapacityUnits = attrs.write 17 | 18 | return this.table.database.request( 19 | "UpdateTable", 20 | { 21 | TableName: this.table.TableName, 22 | ProvisionedThroughput: this.toJSON() 23 | }, 24 | function(err, data) { 25 | if (err) return cb(err) 26 | 27 | cb(null, self.parse(data.TableDescription.ProvisionedThroughput)) 28 | } 29 | ) 30 | }, 31 | 32 | parse: function(data) { 33 | if (data.LastIncreaseDateTime) { 34 | this.LastIncreaseDateTime = new Date(data.LastIncreaseDateTime * 1000) 35 | } 36 | 37 | if (data.LastDecreaseDateTime) { 38 | this.LastDecreaseDateTime = new Date(data.LastDecreaseDateTime * 1000) 39 | } 40 | 41 | this.ReadCapacityUnits = data.ReadCapacityUnits 42 | this.WriteCapacityUnits = data.WriteCapacityUnits 43 | 44 | return this 45 | } 46 | } 47 | 48 | module.exports = ProvisionedThroughput 49 | -------------------------------------------------------------------------------- /lib/KeySchema.js: -------------------------------------------------------------------------------- 1 | var types = {S: String, N: Number} 2 | , keys = ["HashKeyElement", "RangeKeyElement"] 3 | 4 | function KeySchema(attrs) { 5 | function setSchemaKey(pos, keyName, keyType) { 6 | if (pos > 1) throw new Error("More than two keys specified.") 7 | this[keys[pos]] = new SchemaKey(keyName, keyType) 8 | } 9 | 10 | if (attrs) { 11 | if (Array.isArray(attrs)) { 12 | attrs.forEach(function(element, pos) { 13 | setSchemaKey.call(this, pos, element[0], element[1]) 14 | }, this) 15 | } else { 16 | Object.keys(attrs).forEach(function(name, pos) { 17 | setSchemaKey.call(this, pos, name, attrs[name]) 18 | }, this) 19 | } 20 | } 21 | } 22 | 23 | KeySchema.prototype = { 24 | parse: function(data) { 25 | this.HashKeyElement = (new SchemaKey).parse(data.HashKeyElement) 26 | 27 | if (data.RangeKeyElement) { 28 | this.RangeKeyElement = (new SchemaKey).parse(data.RangeKeyElement) 29 | } 30 | 31 | return this 32 | } 33 | } 34 | 35 | function SchemaKey(name, type) { 36 | this.AttributeName = name 37 | this.AttributeType = type 38 | } 39 | 40 | SchemaKey.prototype = { 41 | toJSON: function() { 42 | return { 43 | AttributeName: this.AttributeName, 44 | AttributeType: this.AttributeType.name.charAt(0) 45 | } 46 | }, 47 | 48 | parse: function(data) { 49 | this.AttributeName = data.AttributeName, 50 | this.AttributeType = types[data.AttributeType] 51 | 52 | return this 53 | } 54 | } 55 | 56 | module.exports = KeySchema 57 | -------------------------------------------------------------------------------- /lib/Account.js: -------------------------------------------------------------------------------- 1 | var crypto = require("crypto") 2 | , Database = require("./Database") 3 | , Session = require("./Session") 4 | 5 | , regions = { 6 | "us-east-1": true, 7 | "us-west-1": true, 8 | "us-west-2": true, 9 | "ap-northeast-1": true, 10 | "ap-southeast-1": true, 11 | "eu-west-1": true 12 | } 13 | 14 | function Account(credentials) { 15 | this.session = new Session(credentials) 16 | } 17 | 18 | Account.prototype.get = function(host) { 19 | var database = new Database(this) 20 | , badHost = !(host in regions) 21 | 22 | if (badHost) { 23 | console.error( 24 | "WARN: Assuming 'us-east-1' for backward compatibility.\n" + 25 | "Please use client.get(region).get(table) instead, as this will soon be deprecated." 26 | ) 27 | 28 | database.host = "dynamodb.us-east-1.amazonaws.com" 29 | return database.get(host) 30 | } 31 | 32 | database.host = "dynamodb." + host + ".amazonaws.com" 33 | return database 34 | } 35 | 36 | Account.prototype.sign = function sign(request, cb) { 37 | this.session.fetch(function(err, session) { 38 | if (err) return cb(err) 39 | 40 | var hash = crypto.createHash("sha256") 41 | , payload 42 | 43 | request.headers["x-amz-security-token"] = session.token 44 | 45 | payload = new Buffer(request.toString(), "utf8") 46 | hash = hash.update(payload).digest() 47 | 48 | request.headers["x-amzn-authorization"] = "AWS3 " + [ 49 | "AWSAccessKeyId=" + session.tokenCredentials.accessKeyId, 50 | "Algorithm=HmacSHA256", 51 | "SignedHeaders=host;x-amz-date;x-amz-security-token;x-amz-target", 52 | "Signature=" + session.tokenCredentials.sign(hash) 53 | ] 54 | 55 | cb(null, request) 56 | }) 57 | } 58 | 59 | module.exports = Account 60 | -------------------------------------------------------------------------------- /lib/Scan.js: -------------------------------------------------------------------------------- 1 | var Predicates = require("./Predicates") 2 | , Attributes = require("./Attributes") 3 | 4 | function Scan(table, database) { 5 | this.TableName = table 6 | 7 | Object.defineProperty( 8 | this, "database", 9 | {value: database, enumerable: false} 10 | ) 11 | } 12 | 13 | Scan.prototype = { 14 | filter: function(predicates) { 15 | this.ScanFilter = new Predicates(predicates) 16 | 17 | return this 18 | }, 19 | 20 | get: function() { 21 | this.AttributesToGet = Array.prototype.concat.apply([], arguments) 22 | 23 | return this 24 | }, 25 | 26 | count: function() { 27 | this.Count = true 28 | 29 | return this 30 | }, 31 | 32 | limit: function(limit) { 33 | if(isNaN(limit) || ((limit|0) != limit) || (limit|0) < 1) { 34 | throw new Error("Limit should be an natural number"); 35 | } 36 | 37 | this.Limit = limit; 38 | return this; 39 | }, 40 | 41 | fetch: function(cb) { 42 | var self = this 43 | , response = [] 44 | 45 | !function loop(ExclusiveStartKey) { 46 | self.database.request( 47 | "Scan", 48 | self, 49 | function fetch(err, data) { 50 | if (err) return cb(err) 51 | 52 | if (self.Count) return cb(null, data.Count) 53 | 54 | data.Items.forEach(function(item) { 55 | response.push(Attributes.prototype.parse(item)) 56 | }) 57 | 58 | if (data.LastEvaluatedKey) { 59 | if (self.Limit != null && self.Limit < response.length) { 60 | loop(data.LastEvaluatedKey) 61 | } else { 62 | cb(null, response) 63 | } 64 | } else { 65 | cb(null, response) 66 | } 67 | } 68 | ) 69 | }(this.ExclusiveStartKey) 70 | 71 | } 72 | } 73 | 74 | module.exports = Scan 75 | -------------------------------------------------------------------------------- /lib/Item.js: -------------------------------------------------------------------------------- 1 | var Key = require("./Key") 2 | , Attributes = require("./Attributes") 3 | , Update = require("./Update") 4 | 5 | function Item(key, table, database) { 6 | Object.defineProperty( 7 | this, "database", 8 | {value: database, enumerable: false} 9 | ) 10 | 11 | if (key) this.Key = Key(key) 12 | if (table) this.TableName = table 13 | } 14 | 15 | Item.prototype = { 16 | get: function(attrs) { 17 | this.AttributesToGet = attrs 18 | 19 | return this 20 | }, 21 | 22 | update: function(key, value) { 23 | var update = new Update(this.TableName, this.database) 24 | update.Key = this.Key 25 | 26 | if (!key) return update 27 | 28 | if (typeof key == "function") { 29 | key.call(update, update) 30 | return update 31 | } 32 | 33 | return update.put(key, value) 34 | }, 35 | 36 | fetch: function(opts, cb) { 37 | if (typeof opts == "function") cb = opts, opts = {} 38 | 39 | var self = this 40 | , attrs = {} 41 | , data = { 42 | TableName: this.TableName, 43 | Key: this.Key 44 | } 45 | 46 | if (this.AttributesToGet) data.AttributesToGet = this.AttributesToGet 47 | 48 | if (opts.consistent) data.ConsistentRead = true 49 | 50 | this.database.request( 51 | "GetItem", 52 | data, 53 | function(err, data) { 54 | if (err || !data.Item) return cb(err) 55 | 56 | cb(null, Attributes.prototype.parse(data.Item)) 57 | } 58 | ) 59 | 60 | return this 61 | }, 62 | 63 | destroy: function(cb) { 64 | this.database.request( 65 | "DeleteItem", 66 | {TableName: this.TableName, Key: this.Key}, 67 | function(err, data) { 68 | if (err || !data.Item) return cb(err) 69 | 70 | cb(null, Attributes.prototype.parse(data.Item)) 71 | } 72 | ) 73 | } 74 | } 75 | 76 | module.exports = Item 77 | -------------------------------------------------------------------------------- /test/tests/Database.js: -------------------------------------------------------------------------------- 1 | var should = require("should") 2 | , dynamo = require("../../") 3 | , client = dynamo.createClient() 4 | , db = client.get("us-east-1") 5 | 6 | describe("Database", function() { 7 | describe("#get()", function() { 8 | it("should return the appropriate object", function() { 9 | var table = db.get("table") 10 | , batch = db.get(function(){}) 11 | , item = db.get("table", {id: 123}) 12 | 13 | table.should.have.property("TableName", "table") 14 | table.should.have.property("database", db) 15 | 16 | batch.should.have.property("RequestItems") 17 | batch.should.have.property("database", db) 18 | 19 | item.should.have.property("TableName", "table") 20 | item.should.have.property("Key") 21 | item.should.have.property("database", db) 22 | }) 23 | }) 24 | 25 | describe("#add()", function() { 26 | it("should return a table", function() { 27 | var table = db.add({name: "table", schema: {id: 123}}) 28 | 29 | table.should.have.property("TableName", "table") 30 | table.should.have.property("KeySchema") 31 | }) 32 | }) 33 | 34 | describe("#put()", function() { 35 | it("should return an update", function() { 36 | var update = db.put("table", {id: 123}) 37 | 38 | update.should.have.property("TableName", "table") 39 | update.should.have.property("Item") 40 | update.Item.should.have.property("id") 41 | }) 42 | }) 43 | 44 | describe("#fetch()", function() { 45 | it("should return a hash of tables", function(done) { 46 | db.fetch(function(err, database) { 47 | should.not.exist(err) 48 | should.exist(database) 49 | database.should.equal(db) 50 | 51 | database.should.have.property("tables") 52 | 53 | database.tables.should.have.property("DYNAMO_TEST_TABLE_1") 54 | database.tables.should.have.property("DYNAMO_TEST_TABLE_2") 55 | 56 | done() 57 | }) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /lib/Request.js: -------------------------------------------------------------------------------- 1 | var http = require("http") 2 | , crypto = require("crypto") 3 | 4 | function Request(host, target, data) { 5 | var headers = this.headers = new Headers 6 | 7 | this.json = JSON.stringify(data) 8 | 9 | headers["x-amz-target"] = Request.prototype.target + target 10 | headers["Host"] = this.host = host 11 | headers["Content-Length"] = Buffer.byteLength(this.json) 12 | } 13 | 14 | Request.prototype = { 15 | method: "POST", 16 | pathname: "/", 17 | target: "DynamoDB_20111205.", 18 | data: {}, 19 | 20 | toString: function() { 21 | return this.method + 22 | "\n" + this.pathname + 23 | "\n" + 24 | "\n" + this.headers + 25 | "\n" + 26 | "\n" + this.json 27 | }, 28 | 29 | send: function(cb) { 30 | var request = http.request(this, function(res) { 31 | var json = "" 32 | 33 | res.setEncoding('utf8') 34 | 35 | res.on("data", function(chunk){ json += chunk }) 36 | res.on("end", function() { 37 | var error, response = JSON.parse(json) 38 | 39 | if (res.statusCode == 200) return cb(null, response) 40 | 41 | error = new Error 42 | error.name = response.__type 43 | error.message = response.message 44 | error.statusCode = res.statusCode 45 | 46 | cb(error) 47 | }) 48 | }) 49 | 50 | request.on("error", cb) 51 | 52 | request.write(this.json) 53 | request.end() 54 | } 55 | } 56 | 57 | function Headers() { 58 | this["x-amz-date"] = this["Date"] = (new Date).toUTCString() 59 | this["Content-Type"] = Headers.prototype["Content-Type"] 60 | } 61 | 62 | Headers.prototype = { 63 | "Content-Type": "application/x-amz-json-1.0", 64 | 65 | toString: function() { 66 | return "host:" + this["Host"] + 67 | "\nx-amz-date:" + this["x-amz-date"] + 68 | "\nx-amz-security-token:" + this["x-amz-security-token"] + 69 | "\nx-amz-target:" + this["x-amz-target"] 70 | } 71 | } 72 | 73 | Request.Headers = Headers 74 | module.exports = Request 75 | -------------------------------------------------------------------------------- /test/tests/Table.js: -------------------------------------------------------------------------------- 1 | var should = require("should") 2 | , dynamo = require("../../") 3 | , client = dynamo.createClient() 4 | , db = client.get("us-east-1") 5 | 6 | describe("Table", function() { 7 | describe("#get", function() { 8 | it("should return an item", function() { 9 | var item = db.get("DYNAMO_TEST_TABLE_1").get({id: "0"}) 10 | 11 | item.should.have.property("Key") 12 | }) 13 | }) 14 | 15 | describe("#fetch()", function() { 16 | it("should return a table", function(done) { 17 | db.get("DYNAMO_TEST_TABLE_1").fetch(function(err, table) { 18 | should.not.exist(err) 19 | should.exist(table) 20 | 21 | table.should.have.property("TableName", "DYNAMO_TEST_TABLE_1") 22 | done() 23 | }) 24 | }) 25 | }) 26 | 27 | describe("#scan", function() { 28 | it("should return matching items", function(done) { 29 | db.get("DYNAMO_TEST_TABLE_1") 30 | .scan({ 31 | name: {"in": ["Paul", "George"]}, 32 | id: {">": "2"} 33 | }) 34 | .fetch(function(err, items) { 35 | should.not.exist(err) 36 | should.exist(items) 37 | items.should.have.property("0") 38 | items[0].should.have.property("id", "3") 39 | items[0].should.have.property("name", "George") 40 | 41 | done() 42 | }) 43 | }) 44 | }) 45 | 46 | describe("#query", function() { 47 | it("should return matching items", function(done) { 48 | db.get("DYNAMO_TEST_TABLE_2") 49 | .query({ 50 | id: "2", 51 | date: {">": new Date - 60000} 52 | }) 53 | .fetch(function(err, items) { 54 | should.not.exist(err) 55 | should.exist(items) 56 | 57 | items 58 | .filter(function(item){ return item.id === "2" }) 59 | .should.have.length(items.length) 60 | 61 | items 62 | .filter(function(item){ return item.date < new Date - 60000 }) 63 | .should.have.length(items.length) 64 | 65 | done() 66 | }) 67 | }) 68 | }) 69 | 70 | }) 71 | -------------------------------------------------------------------------------- /lib/Predicates.js: -------------------------------------------------------------------------------- 1 | var Value = require("./Value") 2 | , isArray = Array.isArray 3 | 4 | function Predicates(name, operator, value) { 5 | switch (typeof name) { 6 | case "object": 7 | Object.keys(name).forEach(function(key) { 8 | Predicates.call(this, key, name[key]) 9 | }, this) 10 | 11 | return 12 | } 13 | 14 | if (Object.prototype.hasOwnProperty.call(this, name)) { 15 | throw new Error("Property '" + name + "'' can only be defined once.") 16 | } 17 | 18 | switch (typeof operator) { 19 | case "object": 20 | Object.keys(operator).forEach(function(key) { 21 | Predicates.call(this, name, key, operator[key]) 22 | }, this) 23 | 24 | return 25 | 26 | case "undefined": 27 | operator = "!=" 28 | value = null 29 | break 30 | } 31 | 32 | switch (typeof value) { 33 | case "undefined": 34 | value = operator 35 | operator = "==" 36 | break 37 | } 38 | 39 | if (!isArray(value)) value = [value] 40 | 41 | switch (operator) { 42 | case "!=": 43 | if (value[0] === null) operator = "NOT_NULL", value = [] 44 | else operator = "NE" 45 | break 46 | 47 | case "==": 48 | if (value[0] === null) operator = "NULL", value = [] 49 | else operator = "EQ" 50 | break 51 | 52 | case ">": 53 | operator = "GT" 54 | break 55 | 56 | case "<": 57 | operator = "LT" 58 | break 59 | 60 | case ">=": 61 | operator = value.length > 1 ? "BETWEEN" : "GE" 62 | break 63 | 64 | case "<=": 65 | operator = value.length > 1 ? "BETWEEN" : "LE" 66 | break 67 | 68 | case "contains": 69 | operator = "CONTAINS" 70 | break 71 | 72 | case "!contains": 73 | operator = "NOT_CONTAINS" 74 | break 75 | 76 | case "startsWith": 77 | operator = "BEGINS_WITH" 78 | break 79 | 80 | case "in": 81 | operator = "IN" 82 | break 83 | } 84 | 85 | this[name] = { 86 | ComparisonOperator: operator, 87 | AttributeValueList: value.map(Value) 88 | } 89 | } 90 | 91 | module.exports = Predicates 92 | -------------------------------------------------------------------------------- /lib/Query.js: -------------------------------------------------------------------------------- 1 | var Predicates = require("./Predicates") 2 | , Attributes = require("./Attributes") 3 | 4 | function Query(table, database) { 5 | this.TableName = table 6 | 7 | Object.defineProperty( 8 | this, "database", 9 | {value: database, enumerable: false} 10 | ) 11 | } 12 | 13 | Query.prototype = { 14 | get: function() { 15 | this.AttributesToGet = Array.prototype.concat.apply([], arguments) 16 | 17 | return this 18 | }, 19 | 20 | reverse: function() { 21 | this.ScanIndexForward = false 22 | 23 | return this 24 | }, 25 | 26 | filter: function(predicates) { 27 | var predicates = new Predicates(predicates) 28 | , keys = Object.keys(predicates) 29 | , hashKey = keys[0] 30 | , rangeKey = keys[1] 31 | 32 | if (predicates[hashKey].ComparisonOperator != "EQ") { 33 | throw new Error("Query hash key comparison must be '=='.") 34 | } 35 | 36 | this.HashKeyValue = predicates[hashKey].AttributeValueList[0] 37 | if (rangeKey) { 38 | this.RangeKeyCondition = { 39 | ComparisonOperator: predicates[rangeKey].ComparisonOperator, 40 | AttributeValueList: predicates[rangeKey].AttributeValueList 41 | } 42 | } 43 | 44 | return this 45 | }, 46 | 47 | count: function() { 48 | this.Count = true 49 | 50 | return this 51 | }, 52 | 53 | limit: function(limit) { 54 | if(isNaN(limit) || ((limit|0) != limit) || (limit|0) < 1) { 55 | throw new Error("Limit should be an natural number"); 56 | } 57 | 58 | this.Limit = limit; 59 | return this; 60 | }, 61 | 62 | fetch: function(opts, cb) { 63 | var self = this 64 | , response = [] 65 | 66 | if (typeof opts == "function") cb = opts, opts = {} 67 | if (opts.consistent) this.ConsistentRead = true 68 | 69 | !function loop(ExclusiveStartKey) { 70 | self.database.request( 71 | "Query", 72 | self, 73 | function fetch(err, data) { 74 | if (err) return cb(err) 75 | 76 | if (self.Count) return cb(null, data.Count) 77 | 78 | data.Items.forEach(function(item) { 79 | response.push(Attributes.prototype.parse(item)) 80 | }) 81 | 82 | if (data.LastEvaluatedKey) { 83 | if (self.Limit != null && self.Limit < response.length) { 84 | loop(data.LastEvaluatedKey) 85 | } else { 86 | cb(null, response) 87 | } 88 | } else { 89 | cb(null, response) 90 | } 91 | } 92 | ) 93 | }(this.ExclusiveStartKey) 94 | } 95 | } 96 | 97 | module.exports = Query 98 | -------------------------------------------------------------------------------- /lib/Batch.js: -------------------------------------------------------------------------------- 1 | var Table = require("./Table") 2 | , Key = require("./Key") 3 | , Attributes = require("./Attributes") 4 | , push = Array.prototype.push 5 | , isArray = Array.isArray 6 | 7 | function Batch(database) { 8 | Object.defineProperty( 9 | this, "database", 10 | {value: database, enumerable: false} 11 | ) 12 | 13 | this.RequestItems = {} 14 | } 15 | 16 | Batch.prototype = { 17 | get: function(name, keys, attrs) { 18 | var items = this.RequestItems 19 | , tableBatch 20 | 21 | switch (typeof name) { 22 | case "function": 23 | name.call(this, this) 24 | return this 25 | 26 | case "object": 27 | for (var item in name) this.get(item, name[item]) 28 | return this 29 | 30 | case "string": 31 | tableBatch = items[name] || (items[name] = new TableBatch) 32 | 33 | if (!keys) return tableBatch 34 | 35 | tableBatch.get(keys, attrs) 36 | return this 37 | } 38 | }, 39 | 40 | fetch: function(cb) { 41 | var self = this 42 | , response = {} 43 | 44 | !function loop(RequestItems) { 45 | self.database.request( 46 | "BatchGetItem", 47 | {RequestItems: RequestItems}, 48 | function fetch(err, data) { 49 | if (err) return cb(err) 50 | 51 | Object.keys(data.Responses).forEach(function(name) { 52 | var table = response[name] || (response[name] = []) 53 | 54 | data.Responses[name].Items.forEach(function(attrs) { 55 | table.push(Attributes.prototype.parse(attrs)) 56 | }) 57 | }) 58 | 59 | if (data.UnprocessedKeys) loop(data.UnprocessedKeys) 60 | 61 | else cb(err, response) 62 | } 63 | ) 64 | }(this.RequestItems) 65 | } 66 | } 67 | 68 | function TableBatch() { 69 | this.Keys = [] 70 | this.AttributesToGet = [] 71 | } 72 | 73 | TableBatch.prototype = { 74 | toJSON: function() { 75 | var obj = {Keys: this.Keys} 76 | 77 | if (this.AttributesToGet.length) obj.AttributesToGet = this.AttributesToGet 78 | 79 | return obj 80 | }, 81 | 82 | get: function(keys, attrs){ 83 | if (typeof keys == "function") { 84 | keys.call(this, this) 85 | return this 86 | } 87 | 88 | if (!isArray(keys)) keys = [keys] 89 | 90 | push.apply(this.Keys, keys.map(Key)) 91 | 92 | if (attrs) attrs.forEach(function(name) { 93 | if (this.AttributesToGet.indexOf(name) < 0) { 94 | this.AttributesToGet.push(name) 95 | } 96 | }, this) 97 | 98 | return this 99 | } 100 | } 101 | 102 | module.exports = Batch 103 | -------------------------------------------------------------------------------- /lib/Update.js: -------------------------------------------------------------------------------- 1 | var Predicates = require("./Predicates") 2 | , Attributes = require("./Attributes") 3 | , Value = require("./Value") 4 | 5 | function Update(table, database) { 6 | this.TableName = table 7 | 8 | Object.defineProperty( 9 | this, "database", 10 | {value: database, enumerable: false} 11 | ) 12 | } 13 | 14 | Update.prototype = { 15 | when: function(name, operator, value) { 16 | var predicates = new Predicates(name, operator, value) 17 | , expectation = this.Expected = {} 18 | , names = Object.keys(predicates) 19 | 20 | if (names.length > 1) { 21 | throw new Error("Only one condition allowed per expectation.") 22 | } 23 | 24 | name = names[0] 25 | operator = predicates[name].ComparisonOperator 26 | value = predicates[name].AttributeValueList[0] 27 | 28 | switch (operator) { 29 | case "NULL": 30 | expectation[name] = {Exists: false} 31 | return this 32 | 33 | case "NOT_NULL": 34 | expectation[name] = {Exists: true} 35 | return this 36 | 37 | case "EQ": 38 | expectation[name] = {Value: value} 39 | return this 40 | } 41 | 42 | throw new Error("Invalid expectation: " + [name, operator, value]) 43 | }, 44 | 45 | returning: function(constant) { 46 | this.ReturnValues = constant 47 | 48 | return this 49 | }, 50 | 51 | update: function(action, key, value) { 52 | var updates = this.AttributeUpdates || (this.AttributeUpdates = {}) 53 | 54 | if (updates[key]) { 55 | throw new Error("Attribute '" + key + "' cannot be updated more than once.") 56 | } 57 | 58 | updates[key] = {Action: action} 59 | 60 | if (value != null) updates[key].Value = Value(value) 61 | 62 | return this 63 | }, 64 | 65 | put: function(key, value) { 66 | return this.update("PUT", key, value) 67 | }, 68 | 69 | add: function(key, value) { 70 | return this.update("ADD", key, value) 71 | }, 72 | 73 | remove: function(key, value) { 74 | return this.update("DELETE", key, value) 75 | }, 76 | 77 | save: function(cb) { 78 | if (this.Item) { 79 | this.database.request( 80 | "PutItem", 81 | this, 82 | function(err, data) { 83 | if (err) return cb(err) 84 | 85 | if (data = data.Attributes) { 86 | cb(null, Attributes.prototype.parse(data)) 87 | } 88 | 89 | else cb() 90 | } 91 | ) 92 | 93 | return this 94 | } 95 | 96 | if (this.AttributeUpdates) { 97 | this.database.request( 98 | "UpdateItem", 99 | this, 100 | cb 101 | ) 102 | 103 | return this 104 | } 105 | 106 | throw new Error("Nothing to save.") 107 | } 108 | } 109 | 110 | module.exports = Update 111 | -------------------------------------------------------------------------------- /lib/Database.js: -------------------------------------------------------------------------------- 1 | var Batch = require("./Batch") 2 | , Table = require("./Table") 3 | , Request = require("./Request") 4 | 5 | function Database(account) { 6 | Object.defineProperty( 7 | this, "account", 8 | {value: account, enumerable: false} 9 | ) 10 | 11 | this.tables = {} 12 | } 13 | 14 | Database.prototype = { 15 | get: function(table, key) { 16 | if (typeof table != "string") return new Batch(this).get(table) 17 | 18 | table = new Table(table, this) 19 | 20 | return key ? table.get(key) : table 21 | }, 22 | 23 | put: function(table, item) { 24 | return this.get(table).put(item) 25 | }, 26 | 27 | add: function(table) { 28 | return new Table(table, this) 29 | }, 30 | 31 | remove: function(table, cb) { 32 | return this.get(table).destroy(cb) 33 | }, 34 | 35 | fetch: function(cb) { 36 | var self = this 37 | 38 | !function loop(cursor) { 39 | self.request( 40 | "ListTables", 41 | {ExclusiveStartTableName: cursor}, 42 | function(err, data) { 43 | if (err) return cb(err) 44 | 45 | data.TableNames.forEach(function(name) { 46 | self.tables[name] = new Table(name, self) 47 | }) 48 | 49 | if (cursor = data.LastEvaluatedTableName) loop(cursor) 50 | 51 | else cb(null, self) 52 | } 53 | ) 54 | }() 55 | 56 | return this 57 | }, 58 | 59 | request: function request(target, data, cb) { 60 | var self = this 61 | , req = new Request(this.host, target, data) 62 | 63 | this.account.sign(req, function(err, req) { 64 | if (err) cb(err) 65 | 66 | else req.send(function(err, data) { 67 | if (!err || err.statusCode >= 500) cb(err, data) 68 | 69 | else cb(err, data, request.bind(self, target, data, cb)) 70 | }) 71 | }) 72 | 73 | return this 74 | }, 75 | 76 | listTables: function(data, cb) { 77 | return this.request("ListTables", data, cb) 78 | }, 79 | 80 | createTable: function(data, cb) { 81 | return this.request("CreateTable", data, cb) 82 | }, 83 | 84 | describeTable: function(data, cb) { 85 | return this.request("DescribeTable", data, cb) 86 | }, 87 | 88 | updateTable: function(data, cb) { 89 | return this.request("UpdateTable", data, cb) 90 | }, 91 | 92 | deleteTable: function(data, cb) { 93 | return this.request("DeleteTable", data, cb) 94 | }, 95 | 96 | scan: function(data, cb) { 97 | return this.request("Scan", data, cb) 98 | }, 99 | 100 | query: function(data, cb) { 101 | return this.request("Query", data, cb) 102 | }, 103 | 104 | batchGetItem: function(data, cb) { 105 | return this.request("BatchGetItem", data, cb) 106 | }, 107 | 108 | batchWriteItem: function(data, cb) { 109 | return this.request("BatchWriteItem", data, cb) 110 | }, 111 | 112 | getItem: function(data, cb) { 113 | return this.request("GetItem", data, cb) 114 | }, 115 | 116 | putItem: function(data, cb) { 117 | return this.request("PutItem", data, cb) 118 | }, 119 | 120 | updateItem: function(data, cb) { 121 | return this.request("UpdateItem", data, cb) 122 | }, 123 | 124 | deleteItem: function(data, cb) { 125 | return this.request("DeleteItem", data, cb) 126 | } 127 | } 128 | 129 | module.exports = Database 130 | -------------------------------------------------------------------------------- /lib/Session.js: -------------------------------------------------------------------------------- 1 | var https = require("https") 2 | , crypto = require("crypto") 3 | , Credentials = require("./Credentials") 4 | 5 | function Session(attrs) { 6 | this.sessionCredentials = new Credentials(attrs) 7 | this.tokenCredentials = null 8 | this.listeners = [] 9 | } 10 | 11 | Session.prototype = { 12 | duration: 60 * 60 * 1000, 13 | refreshPadding: 60 * 1000, //refresh 1 minute ahead of time 14 | consumedCapacity: 0, 15 | 16 | fetch: function(cb) { 17 | if ((this.expiration - this.refreshPadding) > new Date) return cb(null, this) 18 | 19 | this.listeners.push(cb) > 1 || this.refresh() 20 | }, 21 | 22 | refresh: function() { 23 | var req = new Request 24 | 25 | req.query.DurationSeconds = 0 | this.duration / 1000 26 | req.query.AWSAccessKeyId = this.sessionCredentials.accessKeyId 27 | req.query.Signature = this.sessionCredentials.sign(req.toString(), "sha256", "base64") 28 | 29 | req.send(function(err, data) { 30 | var listeners = this.listeners.splice(0) 31 | 32 | if (!err) { 33 | this.expiration = new Date(data.expiration) 34 | this.tokenCredentials = new Credentials(data) 35 | this.token = data.sessionToken 36 | } 37 | 38 | listeners.forEach(function(cb) { 39 | cb(err, err ? null : this) 40 | }, this) 41 | }.bind(this)) 42 | } 43 | } 44 | 45 | function Request() { 46 | this.query = new Query 47 | } 48 | 49 | Request.prototype = { 50 | method: "GET", 51 | host: "sts.amazonaws.com", 52 | pathname: "/", 53 | 54 | toString: function() { 55 | return this.method + 56 | "\n" + this.host + 57 | "\n" + this.pathname + 58 | "\n" + this.query.toString().slice(1) 59 | }, 60 | 61 | send: function(cb) { 62 | var signature = encodeURIComponent(this.query.Signature) 63 | , query = this.query + "&Signature=" + signature 64 | , path = Request.prototype.pathname + query 65 | , options = { host: this.host, path: path } 66 | 67 | var req = https.get(options, function(res) { 68 | var xml = "" 69 | 70 | res.on("data", function(chunk){ xml += chunk }) 71 | res.on("end", function() { 72 | var response = new Response(xml) 73 | 74 | if (res.statusCode == 200) cb(null, response) 75 | 76 | else cb(new Error( 77 | response.type + "(" + response.code + ")\n\n" + 78 | response.message 79 | )) 80 | }) 81 | }) 82 | req.on("error", cb); 83 | } 84 | } 85 | 86 | function Query() { 87 | this.Timestamp = (new Date).toISOString().slice(0, 19) + "Z" 88 | } 89 | 90 | Query.prototype = { 91 | Action : "GetSessionToken", 92 | SignatureMethod : "HmacSHA256", 93 | SignatureVersion : "2", 94 | Version : "2011-06-15", 95 | 96 | toString: function() { 97 | return ( 98 | "?AWSAccessKeyId=" + this.AWSAccessKeyId + 99 | "&Action=" + this.Action + 100 | "&DurationSeconds=" + this.DurationSeconds + 101 | "&SignatureMethod=" + this.SignatureMethod + 102 | "&SignatureVersion=" + this.SignatureVersion + 103 | "&Timestamp=" + encodeURIComponent(this.Timestamp) + 104 | "&Version=" + this.Version 105 | ) 106 | } 107 | } 108 | 109 | function Response(xml) { 110 | var tag, key, regexp = /<(\w+)>(.*)=": new Date - 6000 }}) 27 | .get("id", "date", "name") 28 | .reverse() 29 | .fetch(function(err, data){ ... }) 30 | 31 | // Same call, using low-level API 32 | 33 | db.query({ 34 | TableName: "myTable", 35 | HashKeyValue: {S: "123"}, 36 | RangeKeyValue: { 37 | ComparisonOperator: "LE", 38 | AttributeValueList: [{N: "1329912311806"}] 39 | }, 40 | AttributesToGet: ["id", "date", "name"], 41 | ScanIndexForward: false 42 | }, function(err, data){ ... }) 43 | ``` 44 | 45 | Installation 46 | ------------ 47 | 48 | This library has no dependencies, and can be installed from [npm][npm]: 49 | 50 | npm install dynamo 51 | 52 | API 53 | --- 54 | 55 | ### dynamo = require("dynamo") 56 | 57 | This module exposes the `createClient` method, which is the preferred way to interact with dynamo. 58 | 59 | ### client = dynamo.createClient([_credentials_]) 60 | 61 | Returns a client instance attached to the account specified by the given credentials. The credentials can be specified as an object with `accessKeyId` and `secretAccessKey` members such as the following: 62 | 63 | ```javascript 64 | client = dynamo.createClient({ 65 | accessKeyId: "...", // your access key id 66 | secretAccessKey: "..." // your secret access key 67 | }) 68 | ``` 69 | 70 | You can also omit these credentials by storing them in the environment under which the current process is running, as `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. 71 | 72 | If neither of the above are provided, an error will be thrown. 73 | 74 | ### db = client.get(_regionName_) 75 | 76 | Returns a database in the selected region. Currently, DynamoDB supports the following regions: 77 | 78 | - `us-east-1` 79 | - `us-west-1` 80 | - `us-west-2` 81 | - `ap-northeast-1` 82 | - `ap-southeast-1` 83 | - `eu-west-1` 84 | 85 | Once you have a database instance, you can use either of the provided APIs: 86 | 87 | ### [High-level API][high-api] (blue pill) 88 | 89 | The primary purpose of this library is to abstract away the often bizzare API design decisions of DynamoDB, into a composable and intuitive interface based on Database, Table, Item, Batch, Query, and Scan objects. 90 | 91 | See [the wiki][high-api] for more information. 92 | 93 | ### [Low-level API][low-api] (red pill) 94 | 95 | All of the [original DynamoDB operations][api] are provided as methods on database instances. You won't need to use them unless you want to sacrifice a clean interdace for more control, and don't mind learning Amazon's JSON format. 96 | 97 | See [the wiki][low-api] for more information. 98 | 99 | Testing 100 | ------- 101 | 102 | Testing for dynamo is handled using continuous integration against a real DynamoDB instance, under credentials limited to Travis CI. 103 | 104 | If you'd like to run the test stuie with your own credentials, make sure they're set using the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables, and then run the tests: 105 | 106 | npm test 107 | 108 | The test suite creates three tables called `DYNAMO_TEST_TABLE_1`, `DYNAMO_TEST_TABLE_2`, and 'DYNAMO_TEST_TABLE_3` before the tests are run, and then deletes them once the tests are done. Note that you will need to delete them manually in the event that the tests fail. 109 | 110 | To do 111 | ----- 112 | 113 | - Factor out tests into integration tests and unit tests 114 | - Make all callbacks optional, returning an event emitter no callback given 115 | - Add method to specify Limit and ExclusiveStartKey 116 | 117 | Credits 118 | ------- 119 | 120 | - [Travis CI][travis] for an awesome open-source testing service 121 | - [@chriso][chriso] for letting me have the "dynamo" name on npm 122 | - [@skomski][skomski] for turning me on to [IAM credentials][iam] 123 | - [@mranney][mranney] for inspiration from the venerable [node_redis][node_redis] 124 | - [@visionmedia][tj] for making testing easy with [mocha][mocha] and [should.js][should] 125 | 126 | 127 | Copyright 128 | --------- 129 | 130 | Copyright (c) 2012 Jed Schmidt. See LICENSE.txt for details. 131 | 132 | Send any questions or comments [here][twitter]. 133 | 134 | [travis]: http://travis-ci.org/jed/dynamo 135 | [node]: http://nodejs.org 136 | [dynamo]: http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/Introduction.html 137 | [aws]: http://aws.amazon.com 138 | [api]: http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/operationlist.html 139 | [mranney]: https://github.com/mranney 140 | [skomski]: https://github.com/skomski 141 | [node_redis]: https://github.com/mranney/node_redis 142 | [twitter]: http://twitter.com/jedschmidt 143 | [heroku]: http://heroku.com 144 | [mocha]: https://visionmedia.github.com/mocha 145 | [should]: https://github.com/visionmedia/should.js 146 | [tj]: https://github.com/visionmedia 147 | [iam]: http://docs.amazonwebservices.com/IAM/latest/UserGuide/IAM_Introduction.html 148 | [connect]: http://www.senchalabs.org/connect 149 | [chriso]: https://github.com/chriso 150 | [low-api]: https://github.com/jed/dynamo/wiki/Low-level-API 151 | [high-api]: https://github.com/jed/dynamo/wiki/High-level-API 152 | [npm]: http://npmjs.org 153 | --------------------------------------------------------------------------------