diff --git a/.gitignore b/.gitignore index 4b96477..3a1f1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules pnpm-lock.yaml package-lock.json coverage +**/*.d.ts *~ # For misc scripts and experiments: diff --git a/README.md b/README.md index def90a1..dc2ca7c 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,5 @@ We're not on npm yet. In your package.json, include this as ```js -"ppppp-record": "github:staltz/ppppp-record" +"ppppp-dict": "github:staltz/ppppp-dict" ``` diff --git a/lib/index.js b/lib/index.js index 6851c95..a54beae 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,6 @@ const MsgV3 = require('ppppp-db/msg-v3') -const PREFIX = 'record_v1__' +const PREFIX = 'dict_v1__' /** * @typedef {ReturnType} PPPPPDB @@ -18,11 +18,13 @@ const PREFIX = 'record_v1__' * @typedef {string} MsgID * @typedef {`${Subdomain}.${string}`} SubdomainField * @typedef {{ - * update: Record, + * update: { + * [field in string]: any + * }, * supersedes: Array, - * }} RecordData + * }} DictMsgData * @typedef {{ - * record?: { + * dict?: { * ghostSpan?: number * } * }} Config @@ -65,18 +67,18 @@ function fromSubdomain(subdomain) { * @returns {asserts peer is { db: PPPPPDB, close: ClosableHook }} */ function assertDBPlugin(peer) { - if (!peer.db) throw new Error('record plugin requires ppppp-db plugin') + if (!peer.db) throw new Error('dict plugin requires ppppp-db plugin') } /** * @param {{ db: PPPPPDB | null, close: ClosableHook }} peer * @param {Config} config */ -function initRecord(peer, config) { +function initDict(peer, config) { assertDBPlugin(peer) - const ghostSpan = config.record?.ghostSpan ?? 32 - if (ghostSpan < 1) throw new Error('config.record.ghostSpan must be >= 0') + const ghostSpan = config.dict?.ghostSpan ?? 32 + if (ghostSpan < 1) throw new Error('config.dict.ghostSpan must be >= 0') //#region state let accountID = /** @type {string | null} */ (null) @@ -96,10 +98,10 @@ function initRecord(peer, config) { }, /** * @param {string} subdomain - * @returns {Record>} + * @returns {{[field in string]: Array}} */ getAll(subdomain) { - const out = /** @type {Record>} */ ({}) + const out = /** @type {{[field in string]: Array}} */ ({}) for (const [key, value] of this._map.entries()) { if (key.startsWith(subdomain + '.')) { const field = key.slice(subdomain.length + 1) @@ -160,7 +162,7 @@ function initRecord(peer, config) { * @param {Msg | null | undefined} msg * @returns {msg is Msg} */ - function isValidRecordMoot(msg) { + function isValidDictMoot(msg) { if (!msg) return false if (msg.metadata.account !== accountID) return false const domain = msg.metadata.domain @@ -171,9 +173,9 @@ function initRecord(peer, config) { /** * @private * @param {Msg | null | undefined} msg - * @returns {msg is Msg} + * @returns {msg is Msg} */ - function isValidRecordMsg(msg) { + function isValidDictMsg(msg) { if (!msg) return false if (!msg.data) return false if (msg.metadata.account !== accountID) return false @@ -190,7 +192,7 @@ function initRecord(peer, config) { * @param {string} mootID * @param {Msg} moot */ - function learnRecordMoot(mootID, moot) { + function learnDictMoot(mootID, moot) { const subdomain = toSubdomain(moot.metadata.domain) const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID) tangle.add(mootID, moot) @@ -200,9 +202,9 @@ function initRecord(peer, config) { /** * @private * @param {string} msgID - * @param {Msg} msg + * @param {Msg} msg */ - function learnRecordUpdate(msgID, msg) { + function learnDictUpdate(msgID, msg) { const { account, domain } = msg.metadata const mootID = MsgV3.getMootID(account, domain) const subdomain = toSubdomain(domain) @@ -232,14 +234,14 @@ function initRecord(peer, config) { * @param {string} msgID * @param {Msg} msg */ - function maybeLearnAboutRecord(msgID, msg) { + function maybeLearnAboutDict(msgID, msg) { if (msg.metadata.account !== accountID) return - if (isValidRecordMoot(msg)) { - learnRecordMoot(msgID, msg) + if (isValidDictMoot(msg)) { + learnDictMoot(msgID, msg) return } - if (isValidRecordMsg(msg)) { - learnRecordUpdate(msgID, msg) + if (isValidDictMsg(msg)) { + learnDictUpdate(msgID, msg) return } } @@ -279,7 +281,7 @@ function initRecord(peer, config) { /** * @param {string} subdomain - * @param {Record} update + * @param {{[field in string]: any}} update * @param {CB} cb */ function forceUpdate(subdomain, update, cb) { @@ -298,7 +300,7 @@ function initRecord(peer, config) { { account: accountID, domain, data: { update, supersedes } }, (err, rec) => { // prettier-ignore - if (err) return cb(new Error('Failed to create msg when force updating Record', { cause: err })) + if (err) return cb(new Error('Failed to create msg when force-updating Dict', { cause: err })) // @ts-ignore cb(null, true) } @@ -317,12 +319,12 @@ function initRecord(peer, config) { loadPromise = new Promise((resolve, reject) => { for (const rec of peer.db.records()) { if (!rec.msg) continue - maybeLearnAboutRecord(rec.id, rec.msg) + maybeLearnAboutDict(rec.id, rec.msg) } cancelOnRecordAdded = peer.db.onRecordAdded( (/** @type {RecPresent} */ rec) => { try { - maybeLearnAboutRecord(rec.id, rec.msg) + maybeLearnAboutDict(rec.id, rec.msg) } catch (err) { console.error(err) } @@ -355,7 +357,7 @@ function initRecord(peer, config) { const domain = rootMsg.metadata.domain // prettier-ignore - if (!domain.startsWith(PREFIX)) throw new Error(`"${tangleID}" is not a record moot`) + if (!domain.startsWith(PREFIX)) throw new Error(`"${tangleID}" is not a dict moot`) // Discover field roots const fieldRoots = new Set() @@ -393,7 +395,7 @@ function initRecord(peer, config) { * @public * @param {string} id * @param {string} subdomain - * @returns {Record | null} + * @returns {{[field in string]: any} | null} */ function read(id, subdomain) { assertDBPlugin(peer) @@ -405,15 +407,15 @@ function initRecord(peer, config) { else return null } const msgIDs = tangle.topoSort() - const record = /** @type {Record}*/ ({}) + const dict = /** @type {{[field in string]: any}} */ ({}) for (const msgID of msgIDs) { const msg = peer.db.get(msgID) - if (isValidRecordMsg(msg)) { + if (isValidDictMsg(msg)) { const { update } = msg.data - Object.assign(record, update) + Object.assign(dict, update) } } - return record + return dict } /** @@ -445,7 +447,7 @@ function initRecord(peer, config) { // prettier-ignore if (!msg) throw new Error(`isGhostable() msgID "${ghostableMsgID}" does not exist in the database`) // prettier-ignore - if (!isValidRecordMoot(tangle.root)) throw new Error(`isGhostable() tangleID "${tangleID}" is not a record`) + if (!isValidDictMoot(tangle.root)) throw new Error(`isGhostable() tangleID "${tangleID}" is not a dict`) // Discover field roots const fieldRootIDs = new Set() @@ -486,7 +488,7 @@ function initRecord(peer, config) { /** * @public * @param {string} subdomain - * @param {Record} update + * @param {{[field in string]: any}} update * @param {CB} cb */ function update(subdomain, update, cb) { @@ -494,13 +496,13 @@ function initRecord(peer, config) { loaded(() => { if (!accountID) return cb(new Error('Expected account to be loaded')) - const record = read(accountID, subdomain) + const dict = read(accountID, subdomain) // prettier-ignore - if (!record) return cb(new Error(`Cannot update non-existent record "${subdomain}`)) + if (!dict) return cb(new Error(`Cannot update non-existent dict "${subdomain}`)) let hasChanges = false for (const [field, value] of Object.entries(update)) { - if (value !== record[field]) { + if (value !== dict[field]) { hasChanges = true break } @@ -521,12 +523,12 @@ function initRecord(peer, config) { loaded(() => { if (!accountID) return cb(new Error('Expected account to be loaded')) - const record = read(accountID, subdomain) + const dict = read(accountID, subdomain) // prettier-ignore - if (!record) return cb(new Error(`Cannot squeeze non-existent record "${subdomain}`)) - forceUpdate(subdomain, record, (err, _forceUpdated) => { + if (!dict) return cb(new Error(`Cannot squeeze non-existent Dict "${subdomain}"`)) + forceUpdate(subdomain, dict, (err, _forceUpdated) => { // prettier-ignore - if (err) return cb(new Error('Failed to force update when squeezing Record', { cause: err })) + if (err) return cb(new Error(`Failed to force update when squeezing Dict "${subdomain}"`, { cause: err })) // @ts-ignore cb(null, true) }) @@ -550,5 +552,5 @@ function initRecord(peer, config) { } } -exports.name = 'record' -exports.init = initRecord +exports.name = 'dict' +exports.init = initDict diff --git a/package.json b/package.json index 5bba0b1..3f9a071 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "ppppp-record", + "name": "ppppp-dict", "version": "1.0.0", - "description": "Record data structure over append-only logs with pruning", + "description": "Dictionary data structure over append-only logs with pruning", "author": "Andre Staltz ", "license": "MIT", - "homepage": "https://github.com/staltz/ppppp-record", + "homepage": "https://github.com/staltz/ppppp-dict", "repository": { "type": "git", - "url": "git@github.com:staltz/ppppp-record.git" + "url": "git@github.com:staltz/ppppp-dict.git" }, "main": "index.js", "files": [ diff --git a/protospec.md b/protospec.md index a761689..364e23a 100644 --- a/protospec.md +++ b/protospec.md @@ -18,12 +18,12 @@ E-->D & C classDef default fill:#bbb,stroke:#fff0,color:#000 ``` -Reducing the tangle above in a topological sort allows you to build a record +Reducing the tangle above in a topological sort allows you to build a dict (a JSON object) `{age, name}`. ## Msg metadata domain -`msg.metadata.domain` MUST start with `record_v1__`. E.g. `record_v1__profile`. +`msg.metadata.domain` MUST start with `dict_v1__`. E.g. `dict_v1__profile`. ## Msg data @@ -31,7 +31,9 @@ Reducing the tangle above in a topological sort allows you to build a record ```typescript interface MsgData { - update: Record, + update: { + [field in string]: any, + }, supersedes: Array, } ``` @@ -40,11 +42,11 @@ RECOMMENDED that the `msg.data.update` is as flat as possible (no nesting). ## Supersedes links -When you update a field in a record, in the `supersedes` array you MUST point +When you update a field in a dict, in the `supersedes` array you MUST point to the currently-known highest-depth msg that updated that field. The set of *not-transitively-superseded-by-anyone* msgs comprise the -"field roots" of the record. To allow pruning the tangle, we can delete +"field roots" of the dict. To allow pruning the tangle, we can delete (or, if we want to keep metadata, "erase") all msgs preceding the field roots. Suppose the tangle is grown in the order below, then the field roots are diff --git a/test/index.test.js b/test/index.test.js index b5c1a9a..156c5d8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -8,7 +8,7 @@ const Keypair = require('ppppp-keypair') const p = require('util').promisify const { createPeer } = require('./util') -const DIR = path.join(os.tmpdir(), 'ppppp-record') +const DIR = path.join(os.tmpdir(), 'ppppp-dict') rimraf.sync(DIR) const aliceKeypair = Keypair.generate('ed25519', 'alice') @@ -19,7 +19,7 @@ test('setup', async (t) => { peer = createPeer({ keypair: aliceKeypair, path: DIR, - record: { ghostSpan: 4 }, + dict: { ghostSpan: 4 }, }) await peer.db.loaded() @@ -28,143 +28,143 @@ test('setup', async (t) => { domain: 'account', _nonce: 'alice', }) - await p(peer.record.load)(aliceID) + await p(peer.dict.load)(aliceID) - assert.equal(peer.record.getGhostSpan(), 4, 'getGhostSpan') + assert.equal(peer.dict.getGhostSpan(), 4, 'getGhostSpan') }) -test('Record update() and get()', async (t) => { +test('Dict update() and get()', async (t) => { assert( - await p(peer.record.update)('profile', { name: 'alice' }), + await p(peer.dict.update)('profile', { name: 'alice' }), 'update .name' ) assert.deepEqual( - peer.record.read(aliceID, 'profile'), + peer.dict.read(aliceID, 'profile'), { name: 'alice' }, 'get' ) - const fieldRoots1 = peer.record._getFieldRoots('profile') + const fieldRoots1 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots1, - { name: ['PbwnLbJS4oninQ1RPCdgRn'] }, + { name: ['QZSb3GMTRWWUUVLtueNB7Q'] }, 'fieldRoots' ) - assert(await p(peer.record.update)('profile', { age: 20 }), 'update .age') + assert(await p(peer.dict.update)('profile', { age: 20 }), 'update .age') assert.deepEqual( - peer.record.read(aliceID, 'profile'), + peer.dict.read(aliceID, 'profile'), { name: 'alice', age: 20 }, 'get' ) - const fieldRoots2 = peer.record._getFieldRoots('profile') + const fieldRoots2 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots2, - { name: ['PbwnLbJS4oninQ1RPCdgRn'], age: ['9iTTqNabtnXmw4AiZxNMRq'] }, + { name: ['QZSb3GMTRWWUUVLtueNB7Q'], age: ['98QTF8Zip6NYJgmcf96L2K'] }, 'fieldRoots' ) assert.equal( - await p(peer.record.update)('profile', { name: 'alice' }), + await p(peer.dict.update)('profile', { name: 'alice' }), false, 'redundant update .name' ) assert.deepEqual( - peer.record.read(aliceID, 'profile'), + peer.dict.read(aliceID, 'profile'), { name: 'alice', age: 20 }, 'get' ) assert.equal( - await p(peer.record.update)('profile', { name: 'Alice' }), + await p(peer.dict.update)('profile', { name: 'Alice' }), true, 'update .name' ) assert.deepEqual( - peer.record.read(aliceID, 'profile'), + peer.dict.read(aliceID, 'profile'), { name: 'Alice', age: 20 }, 'get' ) - const fieldRoots3 = peer.record._getFieldRoots('profile') + const fieldRoots3 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots3, - { age: ['9iTTqNabtnXmw4AiZxNMRq'], name: ['M2JhM7TE2KX5T5rfnxBh6M'] }, + { age: ['98QTF8Zip6NYJgmcf96L2K'], name: ['49rg6mJFDgdq6kZTE8uedr'] }, 'fieldRoots' ) }) -test('Record squeeze', async (t) => { - assert(await p(peer.record.update)('profile', { age: 21 }), 'update .age') - assert(await p(peer.record.update)('profile', { age: 22 }), 'update .age') - assert(await p(peer.record.update)('profile', { age: 23 }), 'update .age') +test('Dict squeeze', async (t) => { + assert(await p(peer.dict.update)('profile', { age: 21 }), 'update .age') + assert(await p(peer.dict.update)('profile', { age: 22 }), 'update .age') + assert(await p(peer.dict.update)('profile', { age: 23 }), 'update .age') - const fieldRoots4 = peer.record._getFieldRoots('profile') + const fieldRoots4 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots4, - { name: ['M2JhM7TE2KX5T5rfnxBh6M'], age: ['S3xiydrT6Y34Bp1vg6wN7P'] }, + { name: ['49rg6mJFDgdq6kZTE8uedr'], age: ['GE9KcJc5efunBhSTDjy6zX'] }, 'fieldRoots' ) assert.equal( - peer.record._squeezePotential('profile'), + peer.dict._squeezePotential('profile'), 3, 'squeezePotential=3' ) - assert.equal(await p(peer.record.squeeze)('profile'), true, 'squeezed') + assert.equal(await p(peer.dict.squeeze)('profile'), true, 'squeezed') - const fieldRoots5 = peer.record._getFieldRoots('profile') + const fieldRoots5 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots5, - { name: ['Y4JkpPCHN8Avtz4VALaAmK'], age: ['Y4JkpPCHN8Avtz4VALaAmK'] }, + { name: ['Xr7DZdwaANzPByUdRYGb2E'], age: ['Xr7DZdwaANzPByUdRYGb2E'] }, 'fieldRoots' ) assert.equal( - peer.record._squeezePotential('profile'), + peer.dict._squeezePotential('profile'), 0, 'squeezePotential=0' ) assert.equal( - await p(peer.record.squeeze)('profile'), + await p(peer.dict.squeeze)('profile'), false, 'squeeze idempotent' ) - const fieldRoots6 = peer.record._getFieldRoots('profile') + const fieldRoots6 = peer.dict._getFieldRoots('profile') assert.deepEqual(fieldRoots6, fieldRoots5, 'fieldRoots') }) -test('Record isGhostable', (t) => { - const moot = MsgV3.createMoot(aliceID, 'record_v1__profile', aliceKeypair) +test('Dict isGhostable', (t) => { + const moot = MsgV3.createMoot(aliceID, 'dict_v1__profile', aliceKeypair) const mootID = MsgV3.getMsgID(moot) - assert.equal(mootID, peer.record.getFeedID('profile'), 'getFeedID') + assert.equal(mootID, peer.dict.getFeedID('profile'), 'getFeedID') const tangle = peer.db.getTangle(mootID) const msgIDs = tangle.topoSort() - const fieldRoots = peer.record._getFieldRoots('profile') + const fieldRoots = peer.dict._getFieldRoots('profile') assert.deepEqual(fieldRoots.age, [msgIDs[7]]) // Remember from the setup, that ghostSpan=4 assert.equal(msgIDs.length, 8) - assert.equal(peer.record.isGhostable(msgIDs[0], mootID), false) // moot - assert.equal(peer.record.isGhostable(msgIDs[1], mootID), false) - assert.equal(peer.record.isGhostable(msgIDs[2], mootID), false) - assert.equal(peer.record.isGhostable(msgIDs[3], mootID), true) // in ghostSpan - assert.equal(peer.record.isGhostable(msgIDs[4], mootID), true) // in ghostSpan - assert.equal(peer.record.isGhostable(msgIDs[5], mootID), true) // in ghostSpan - assert.equal(peer.record.isGhostable(msgIDs[6], mootID), true) // in ghostSpan - assert.equal(peer.record.isGhostable(msgIDs[7], mootID), false) // field root + assert.equal(peer.dict.isGhostable(msgIDs[0], mootID), false) // moot + assert.equal(peer.dict.isGhostable(msgIDs[1], mootID), false) + assert.equal(peer.dict.isGhostable(msgIDs[2], mootID), false) + assert.equal(peer.dict.isGhostable(msgIDs[3], mootID), true) // in ghostSpan + assert.equal(peer.dict.isGhostable(msgIDs[4], mootID), true) // in ghostSpan + assert.equal(peer.dict.isGhostable(msgIDs[5], mootID), true) // in ghostSpan + assert.equal(peer.dict.isGhostable(msgIDs[6], mootID), true) // in ghostSpan + assert.equal(peer.dict.isGhostable(msgIDs[7], mootID), false) // field root }) -test('Record receives old branched update', async (t) => { - const moot = MsgV3.createMoot(aliceID, 'record_v1__profile', aliceKeypair) +test('Dict receives old branched update', async (t) => { + const moot = MsgV3.createMoot(aliceID, 'dict_v1__profile', aliceKeypair) const mootID = MsgV3.getMsgID(moot) - assert.equal(peer.record.minRequiredDepth(mootID), 7, 'minRequiredDepth') + assert.equal(peer.dict.minRequiredDepth(mootID), 7, 'minRequiredDepth') const tangle = new MsgV3.Tangle(mootID) tangle.add(mootID, moot) @@ -172,7 +172,7 @@ test('Record receives old branched update', async (t) => { const msg = MsgV3.create({ keypair: aliceKeypair, - domain: 'record_v1__profile', + domain: 'dict_v1__profile', account: aliceID, accountTips: [aliceID], data: { update: { age: 2 }, supersedes: [] }, @@ -181,22 +181,22 @@ test('Record receives old branched update', async (t) => { }, }) const rec = await p(peer.db.add)(msg, mootID) - assert.equal(rec.id, 'XZWr3DZFG253awsWXgSkS2', 'msg ID') + assert.equal(rec.id, 'PBq5dgfK9icRVx7SLhyaC5', 'msg ID') - const fieldRoots7 = peer.record._getFieldRoots('profile') + const fieldRoots7 = peer.dict._getFieldRoots('profile') assert.deepEqual( fieldRoots7, { - name: ['Y4JkpPCHN8Avtz4VALaAmK'], - age: ['Y4JkpPCHN8Avtz4VALaAmK', rec.id], + name: ['Xr7DZdwaANzPByUdRYGb2E'], + age: ['Xr7DZdwaANzPByUdRYGb2E', rec.id], }, 'fieldRoots' ) - assert.equal(peer.record.minRequiredDepth(mootID), 1, 'minRequiredDepth') + assert.equal(peer.dict.minRequiredDepth(mootID), 1, 'minRequiredDepth') assert.equal( - peer.record._squeezePotential('profile'), + peer.dict._squeezePotential('profile'), 6, 'squeezePotential=6' )