From e43c169257e47e813fff1fe4c2f188141e5f699d Mon Sep 17 00:00:00 2001 From: Aaron Madsen Date: Sat, 24 Nov 2018 22:36:04 -0700 Subject: [PATCH] WIP: keep iterating on the store API --- lib/store/store.json.js | 393 +++++++++++++++++++++++++++++++++------- 1 file changed, 331 insertions(+), 62 deletions(-) diff --git a/lib/store/store.json.js b/lib/store/store.json.js index 392ce18..d6883b5 100644 --- a/lib/store/store.json.js +++ b/lib/store/store.json.js @@ -1,10 +1,99 @@ 'use strict'; +var crypto = require('crypto'); + function jsonDeepClone(target) { return JSON.parse( JSON.stringify(target) ); } + +function mergeObjects() { + // arguments should be an array of objects. We + // reverse it because the last argument to set + // a value wins. + var args = [].slice.call(arguments).reverse(); + var len = args.length; + if (len === 1) { + return args[0]; + } + + // gather the set of keys from all arguments + var keyLists = args.map(function (arg) { + return Object.keys(arg); + }); + + var keys = Object.keys(keyLists.reduce(function (all, list) { + list.forEach(function (k) { + all[k] = true; + }); + return all; + }, {})); + + // for each key + return keys.reduce(function (target, k) { + // find the first argument (because of the reverse() above) with the key set + var values = []; + var isObject = false; + for (var i = 0; i < len; i++) { + var v = args[i]; + var vType = typeof v; + + if (vType === 'object') { + if (!v) { + // typeof null is object. null is the only falsey object. null represents + // a delete or the end of our argument list; + break; + } + // we need to collect values until we get a non-object, so we can merge them + values.push(v); + isObject = true; + } else if (!isObject) { + if (vType === 'undefined') { + // if the arg actually has the key set this is effectively a "delete" + if (keyList[i].indexOf(k) != -1) { + break; + } + // otherwise we need to check the next argument's value, so we don't break the loop + } else { + values.push(v); + break; + } + } else { + // a previous value was an object, this one isn't + // That means we are done collecting values. + break; + } + } + + if (values.length > 0) { + target[k] = mergeObjects.apply(null, values); + } + + return target; + }, {}); +} + +function prepareZone(zone, options) { + var opts = options || {}; + var timestamp = opts.timestamp || Date.now(); + if (!zone.name) { + zone.name = zone.id; + zone.id = null; + } + if (!zone.id) { + zone.id = crypto.randomBytes(16).toString('hex'); + } + if (!zone.createdAt) { zone.createdAt = timestamp; } + if (!zone.updatedAt || opts.isUpdate) { zone.updatedAt = timestamp; } + + // create a names set for the zone, keyed by record name mapped to + // an object for the various records with that name, by type (A, MX, TXT, etc.) + zone.records = zone.records || {}; + + return zone; +} + /* init() should return an object with: { save: function -> undefined - changes to in memory representation should be persisted @@ -14,14 +103,12 @@ init() should return an object with: { }, zones: { list: function -> list zones, - find: function -> read zone by ???, create: update: delete: }, records: { list: function -> list records, - find: function -> read record by ???, create: update: delete: @@ -35,51 +122,71 @@ module.exports = function init (opts) { // opts = { filepath }; var db = require(opts.filepath); - var stat = require('fs').statSync(opts.filepath); - var crypto = require('crypto'); + var mtime = require('fs').statSync(opts.filepath).mtime.valueOf(); + // - // Manual Migration + // Migration from other formats // - // Convert the primary nameservers from strings to objects with names and IDs. - db.primaryNameservers.forEach(function (ns, i, arr) { - if ('string' === typeof ns) { - ns = { name: ns }; - arr[i] = ns; - } - if (!ns.id) { - ns.id = crypto.randomBytes(16).toString('hex'); - } + // Convert the primary nameservers from an array of strings to objects with names and IDs. + // also switch to the 'peers' name, since we are really interested in the other FQDNs that + // use the same data store and are kept in sync. + var peerList = (!db.peers || Array.isArray(db.peers))? db.peers : Object.keys(db.peers).map(function (p) { + return db.peers[p]; }); + db.peers = [].concat(db.primaryNameservers, peerList).filter(function (p) { + // filer out empty strings, undefined, etc. + return !!p; + }).map(function (ns) { + var peer = ('string' === typeof ns)? ns : { name: ns }; + if (!ns.id) { + peer.id = crypto.randomBytes(16).toString('hex'); + } + return peer; + }).reduce(function (peers, p) { + peers[p.name] = p; + return peers; + }, {}); + delete db.primaryNameservers; // Convert domains to zones and ensure that they have proper IDs and timestamps - db.zones = db.zones || []; - if (db.domains) { - db.zones = db.zones.concat(db.domains); - } - db.zones.forEach(function (zone) { - if (!zone.name) { - zone.name = zone.id; - zone.id = null; - } - if (!zone.id) { - zone.id = crypto.randomBytes(16).toString('hex'); - } - if (!zone.createdAt) { zone.createdAt = stat.mtime.valueOf(); } - if (!zone.updatedAt) { zone.updatedAt = stat.mtime.valueOf(); } + // Organize zones as a set of zone names + var zoneList = (!db.zones || Array.isArray(db.zones))? db.zones : Object.keys(db.zones).map(function (z) { + return db.zones[z]; }); - // Records belong to zones, but they (currently) refer to them by a zone property. - // NOTE/TODO: This may pose problems where the whole list of records is not easily + db.zones = [].concat(db.domains, zoneList).filter(function (z) { + // filer out empty strings, undefined, etc. + return !!z; + }).map(function (zone) { + return prepareZone(zone, { timestamp: mtime }); + }).reduce(function (zones, z) { + zones[z.name] = z; + return zones; + }, {}); + delete db.domains; + + // NOTE: Records belong to zones, but they previously referred to them only by a + // zone property. This may pose problems where the whole list of records is not easily // filtered / kept in memory / indexed and/or retrieved by zone. Traditionally, - // records are stored "within a zone" in a zone file. We may wish to have the - // DB API behave more traditionally, even though some stores (like a SQL database + // records are stored "within a zone" in a zone file. We want to have the store API + // behave more traditionally, even though some stores (like a SQL database // table) might actually store the zone as a property of a record as we currently do. - db.records.forEach(function (record) { + // (This fits with the somewhat unexpected and confusing logic of wildcard records.) + (db.records || []).forEach(function (record) { + // make sure the record has an ID if (!record.id) { record.id = crypto.randomBytes(16).toString('hex'); } + + // put it in it's zone - synthesize one if needed + db.zones[record.zone] = db.zones[record.zone] || prepareZone({ name: record.zone }); + var zone = db.zones[record.zone]; + zone.records[record.name] = zone.records[record.name] || []; + var recordsForName = zone.records[record.name]; + recordsForName.push(record); }); + delete db.records; // Write the migrated data require('fs').writeFileSync(opts.filepath, JSON.stringify(db, null, 2)); @@ -95,7 +202,6 @@ module.exports = function init (opts) { } save._saving = true; - // TODO: replace with something not destructive to original non-json data require('fs').writeFile(opts.filepath, JSON.stringify(db, null, 2), function (err) { console.log('done writing'); var pending = save._pending.splice(0); @@ -112,54 +218,217 @@ module.exports = function init (opts) { }; save._pending = []; + function matchPredicate(predicate) { + return function (toCheck) { + // which items match the predicate? + if (!toCheck) { + return false; + } + + // check all the keys in the predicate - only supporting exact match + // of at least one listed option for all keys right now + if (Object.keys(predicate || {}).some(function (k) { + return [].concat(predicate[k]).indexOf(toCheck[k]) === -1; + })) { + return false; + } + + // we have a match + return true; + }; + } + + function matchZone(predicate) { + var zonenames = !!predicate.name ? [].concat(predicate.name) : Object.keys(db.zones); + var check = matchPredicate(predicate); + // TODO: swap the filter() for a functional style "loop" recursive function + // that lets us return early if we have a limit, etc. + var found = zonenames.filter(function (zonename) { + /* + if (predicate.id && predicate.id !== z.id) { return false; } + if (predicate.name && predicate.name !== z.name) { return false; } + */ + return check(db.zones[zonename]); + }).map(function (zonename) { + return db.zones[zonename]; + }); + + return found; + } + var dbApi = { save: function () { // hide _pending and _saving from callers var args = [].slice.call(arguments); return save.apply(null, args); }, - // primaryNameservers really isn't editable - it's literally the list of FQDN's + // peers really isn't editable - it's literally the list of FQDN's // that this database is replicated to in a multi-master fashion. // // However, lib/store/index.js does plenty to update these records in support // of the SOA records that are built from them (as does this file in the "migration" // section). I'm toying with the idea of not storing them seperately or creating the // SOA records somewhat immediately. - primaryNameservers: { - list: function listNameservers() { - return jsonDeepClone(db.primaryNameservers); - } + peers: function listPeers(cb) { + // Most data stores are going to have an asynchronous storage API. If we need + // synchronous access to the data it is going to have to be cached. If it is + // cached, there is still the issue the cache getting out of sync (a legitimate + // issue anyway). If we explicitly make all of these operations async then we + // have greater flexibility for store implmentations to address these issues. + return setImmediate(cb, null, jsonDeepClone(db.peers)); }, zones: { - list: function listZones() { - return jsonDeepClone(db.zones); - }, - find: function getZone(predicate, cb) { - var found; - db.zones.some(function (z) { - if (z.id && predicate.id === z.id) { found = z; return true; } - if (z.name && predicate.name === z.name) { found = z; return true; } + /* + I'm fairly certan that zone names must be unique and therefore are legitimately + IDs within the zones namespace. This is similarly true of record names within a zone. + I'm not certain that having a distinct ID adds value and it may add confusion / complexity. + */ + // NOTE: `opts` exists so we can add options - like properties to read - easily in the future + // without modifying the function signature + list: function listZones(predicate, opts, cb) { + // TODO: consider whether we should just return the zone names + var found = jsonDeepClone(matchZone(predicate)).map(function (z) { + // This is fairly inefficient!! Consider alternative storage + // that does not require deleting the records like this. + delete z.records; + return z; }); - if (!found) { - cb(null, null); - return; - } - cb(null, jsonDeepClone(found)); - return; + return setImmediate(cb, null, found); }, - create: function() {}, - update: function() {}, - delete: function() {} + // // NOTE: I'm not sure we need a distinct 'find()' operation in the API + // // unless we are going to limit the output of the + // // 'list()' operation in some incompatible way. + // // NOTE: `opts` exists so we can add options - like properties to read - easily in the future + // // without modifying the function signature + // find: function getZone(predicate, opts, cb) { + // if (!predicate.name || predicate.id) { + // return setImmediate(cb, new Error('Finding a zone requires a `name` or `id`')); + // } + // // TODO: implement a limit / short circuit and possibly offset + // // to allow for paging of zone data. + // var found = matchZone(predicate); + // if (!found[0]) { + // // TODO: make error message more specific? + // return setImmediate(cb, new Error('Zone not found')); + // } + + // var z = jsonDeepClone(found[0]); + // delete z.records; + // return setImmediate(cb, null, z); + // }, + create: function createZone(zone, cb) { + // We'll need a lock mechanism of some sort that works + // for simultaneous requests and multiple processes. + matchZone({ name: zone.name }, function (err, matched) { + if (err) { + return setImmediate(cb, err); + } + + var found = matched[0]; + if (found) { + return setImmediate(cb, new Error('Zone ' + zone.name + ' already exists')); + } + + db.zones[zone.name] = prepareZone(zone); + return setImmediate(function () { + cb(null, jsonDeepClone(db.zones[zone.name])); + // release lock + }); + }); + }, + update: function updateZone(zone, cb) { + // We'll need a lock mechanism of some sort that works + // for simultaneous requests and multiple processes. + matchZone({ name: zone.name }, function (err, matched) { + if (err) { + return setImmediate(cb, err); + } + var found = matched[0]; + if (!found) { + return setImmediate(cb, new Error('Zone not found')); + } + // make sure we are not writing records through this interface + delete zone.records; + + var combined = mergeObjects(found, zone); + db.zones[zone.name] = prepareZone(combined, { isUpdate: true }); + return setImmediate(function () { + cb(null, jsonDeepClone(db.zones[zone.name])); + // release lock + }); + }); + }, + delete: function(zone, cb) { + // We'll need a lock mechanism of some sort that works + // for simultaneous requests and multiple processes. + matchZone({ name: zone.name }, function (err, matched) { + if (err) { + return setImmediate(cb, err); + } + var found = matched[0]; + if (!found) { + return setImmediate(cb, new Error('Zone not found')); + } + + delete db.zones[zone.name]; + return setImmediate(function () { + cb(); + // release lock + }); + }); + } }, records: { - list: function listRecords() { - return jsonDeepClone(db.records); + list: function listRecords(rPredicate, cb) { + var recordNames = [].concat(rPredicate.name); + var check = matchPredicate(rPredicate); + + var found = matchZone({ name: rPredicate.zone }).reduce(function (records, zone) { + // get the records from the zone that match the record predicate + var zFound = recordNames.filter(function (name) { + return !!zone.records[name]; + }).map(function (name) { + return zone.records[name].filter(check); + }); + return records.concat(zFound); + }, []); + + return setImmediate(cb, null, jsonDeepClone(found)); }, - find: function getRecord(predicate, cb) { + // find: function getRecord(rPredicate, cb) { + // var recordNames = [].concat(rPredicate.name); + // var check = matchPredicate(rPredicate); + + // // TODO: swap the `filter()` and `map()` for a functional style "loop" + // // recursive function that lets us return early if we have a limit, etc. + // var found = matchZone({ name: rPredicate.zone }).reduce(function (records, zone) { + // // get the records from the zone that match the record predicate + // var zFound = recordNames.filter(function (name) { + // return !!zone.records[name]; + // }).map(function (name) { + // return zone.records[name].filter(check); + // }); + // return records.concat(zFound); + // }, []); + + // return setImmediate(cb, null, jsonDeepClone(found[0])); + // }, + create: function(record, cb) { + var zone = matchZone({ name: record.zone })[0]; + if (!zone) { + return setImmediate(cb, new Error('Unble to find zone ' + record.zone + ' to create record')); + } + + var records = zone.records[record.name] = zone.records[record.name] || []; + var check = matchPredicate(record); + if (records.filter(check)[0]) { + return setImmediate(cb, new Error('Exact record already exists in zone ' + record.zone )); + } + + return setImmediate(cb, null, jsonDeepClone(found)); }, - create: function() {}, - update: function() {}, - delete: function() {} + update: function(record, cb) {}, + delete: function(record, cb) {} } };