commit 59a6b9b11cf76f3f346fb00186c1d0b4020385e1 Author: Andre Staltz Date: Fri Sep 8 16:32:12 2023 +0300 init diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..d6ab510 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + matrix: + node-version: [16.x, 18.x] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ef31a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.vscode +node_modules +pnpm-lock.yaml +package-lock.json +coverage +*~ diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..1d2127c --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,2 @@ +semi: false +singleQuote: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcb6945 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 Andre 'Staltz' Medeiros + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f8d48f --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +**Work in progress** + +## Installation + +We're not on npm yet. In your package.json, include this as + +```js +"ppppp-goals": "github:staltz/ppppp-goals" +``` diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..9b25f93 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,134 @@ +// @ts-ignore +const Obz = require('obz') + +/** + * @typedef {import('ppppp-db/msg-v3').RecPresent} RecPresent + * + * @typedef {'all'} GoalAll + * @typedef {`newest-${number}`} GoalNewest + * @typedef {`oldest-${number}`} GoalOldest + * @typedef {GoalAll|GoalNewest|GoalOldest} GoalDSL + */ + +class Goal { + /** @type {string} */ + #id + + /** @type {'all' | 'newest' | 'oldest'} */ + #type + + /** @type {number} */ + #count + + /** + * @param {string} tangleID + * @param {GoalDSL} goalDSL + * @returns + */ + constructor(tangleID, goalDSL) { + this.#id = tangleID + if (goalDSL === 'all') { + this.#type = 'all' + this.#count = Infinity + return + } + + const matchN = goalDSL.match(/^newest-(\d+)$/) + if (matchN) { + this.#type = 'newest' + this.#count = Number(matchN[1]) + return + } + + const matchO = goalDSL.match(/^oldest-(\d+)$/) + if (matchO) { + this.#type = 'oldest' + this.#count = Number(matchO[1]) + return + } + + throw new Error(`Unrecognized goal DSL: ${goalDSL}`) + } + + get id() { + return this.#id + } + + get type() { + return this.#type + } + + get count() { + return this.#count + } +} + +module.exports = { + name: 'goals', + manifest: {}, + permissions: { + anonymous: {}, + }, + + /** + * @param {any} peer + * @param {{ path: string; keypair: Keypair; }} config + */ + init(peer, config) { + /** @type {Map} */ + const goals = new Map() + const listen = Obz() + + /** + * @param {string} tangleID + * @param {GoalDSL} goalDSL + * @returns {void} + */ + function set(tangleID, goalDSL) { + const goal = new Goal(tangleID, goalDSL) + goals.set(tangleID, goal) + listen.set(goal) + } + + /** + * @param {string} msgID + * @returns {Goal | null} + */ + function getByID(msgID) { + return goals.get(msgID) ?? null + } + + /** + * @param {RecPresent} rec + * @returns {Array} + */ + function getByRec(rec) { + const arr = [] + if (goals.has(rec.id)) { + const goal = /** @type {Goal} */ (goals.get(rec.id)) + arr.push(goal) + } + if (rec.msg) { + for (const tangleID in rec.msg.metadata.tangles) { + if (goals.has(tangleID)) { + const goal = /** @type {Goal} */ (goals.get(tangleID)) + arr.push(goal) + } + } + } + return arr + } + + function list() { + return goals.values() + } + + return { + set, + getByID, + getByRec, + list, + listen, + } + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a94cf6a --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "ppppp-goals", + "version": "1.0.0", + "description": "PPPPP tracker of replication goals", + "author": "Andre Staltz ", + "license": "MIT", + "homepage": "https://github.com/staltz/ppppp-goals", + "repository": { + "type": "git", + "url": "git@github.com:staltz/ppppp-goals.git" + }, + "main": "index.js", + "files": [ + "*.js", + "lib/*.js" + ], + "exports": { + ".": { + "require": "./lib/index.js" + } + }, + "type": "commonjs", + "engines": { + "node": ">=16" + }, + "dependencies": { + "obz": "~1.1.0" + }, + "devDependencies": { + "bs58": "^5.0.0", + "c8": "7", + "ppppp-db": "github:staltz/ppppp-db", + "ppppp-caps": "github:staltz/ppppp-caps", + "ppppp-keypair": "github:staltz/ppppp-keypair", + "prettier": "^2.6.2", + "pretty-quick": "^3.1.3", + "rimraf": "^4.4.0", + "secret-stack": "~7.1.0", + "secret-handshake-ext": "^0.0.8", + "ssb-box": "^1.0.1", + "typescript": "^5.1.3" + }, + "scripts": { + "clean-check": "tsc --build --clean", + "prepublishOnly": "npm run clean-check && tsc --build", + "postpublish": "npm run clean-check", + "test": "npm run clean-check && node --test", + "format-code": "prettier --write \"(lib|test)/**/*.js\"", + "format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"", + "coverage": "c8 --reporter=lcov npm run test" + }, + "husky": { + "hooks": { + "pre-commit": "npm run format-code-staged" + } + } +} diff --git a/test/goals.test.js b/test/goals.test.js new file mode 100644 index 0000000..bebb8b5 --- /dev/null +++ b/test/goals.test.js @@ -0,0 +1,99 @@ +const test = require('node:test') +const assert = require('node:assert') +const { isMapIterator } = require('node:util/types') +const p = require('node:util').promisify +const { createPeer } = require('./util') + +test('set, getByID, list, listen', async (t) => { + const alice = createPeer({ name: 'alice' }) + + await alice.db.loaded() + const aliceID = await p(alice.db.account.create)({ + domain: 'account', + _nonce: 'alice', + }) + const aliceAccountRoot = alice.db.getRecord(aliceID) + + const listened = [] + const stopListening = alice.goals.listen((goal) => { + listened.push(goal) + }) + + { + assert.strictEqual(listened.length, 0, 'listened goals is empty') + alice.goals.set(aliceID, 'newest-5') + assert('set goal done') + assert.strictEqual(listened.length, 1, 'listened goals has one') + } + + { + const goal = alice.goals.getByID(aliceID) + assert.strictEqual(goal.id, aliceID, 'gotten goal id is correct') + assert.strictEqual(goal.type, 'newest', 'gotten goal type is correct') + assert.strictEqual(goal.count, 5, 'gotten goal count is correct') + } + + { + const goals = alice.goals.getByRec(aliceAccountRoot) + assert(Array.isArray(goals), 'gotten rec goals is an array') + assert.strictEqual(goals.length, 1, 'gotten rec goals has one item') + const goal = goals[0] + assert.strictEqual(goal.id, aliceID, 'gotten rec goal id is correct') + } + + { + const listed = alice.goals.list() + assert(isMapIterator(listed), 'list is a map iterator') + const goals = [...listed] + assert(Array.isArray(goals), 'listed goals is an array') + assert.strictEqual(goals.length, 1, 'listed goals has one item') + const goal = goals[0] + assert.strictEqual(goal.id, aliceID, 'listed goal id is correct') + } + + assert.strictEqual(listened.length, 1, 'total listened goals was one') + + assert.strictEqual( + typeof stopListening, + 'function', + 'stopListening is a function' + ) + stopListening() + + await p(alice.close)(true) +}) + +test('getByRec', async (t) => { + const alice = createPeer({ name: 'alice' }) + + await alice.db.loaded() + const aliceID = await p(alice.db.account.create)({ + domain: 'account', + _nonce: 'alice', + }) + + const post1 = await p(alice.db.feed.publish)({ + account: aliceID, + domain: 'post', + data: { text: 'm1' }, + }) + const post2 = await p(alice.db.feed.publish)({ + account: aliceID, + domain: 'post', + data: { text: 'm2' }, + }) + + const feedID = alice.db.feed.getID(aliceID, 'post') + + alice.goals.set(feedID, 'all') + const gottenGoal = alice.goals.getByID(feedID) + assert.strictEqual(gottenGoal.id, feedID, 'gotten goal id is correct') + + const recGoals = alice.goals.getByRec(post1) + assert(Array.isArray(recGoals), 'recGoals is an array') + assert.strictEqual(recGoals.length, 1, 'recGoals has one item') + const recGoal = recGoals[0] + assert.strictEqual(recGoal.id, feedID, 'recGoal id is correct') + + await p(alice.close)(true) +}) diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..0d8e070 --- /dev/null +++ b/test/util.js @@ -0,0 +1,37 @@ +const os = require('node:os') +const path = require('node:path') +const rimraf = require('rimraf') +const caps = require('ppppp-caps') +const Keypair = require('ppppp-keypair') + +function createPeer(opts) { + if (opts.name) { + opts.path ??= path.join(os.tmpdir(), 'tanglesync-' + opts.name) + opts.keypair ??= Keypair.generate('ed25519', opts.name) + opts.name = undefined + } + if (!opts.path) throw new Error('need opts.path in createPeer()') + if (!opts.keypair) throw new Error('need opts.keypair in createPeer()') + + rimraf.sync(opts.path) + return require('secret-stack/bare')() + .use(require('secret-stack/plugins/net')) + .use(require('secret-handshake-ext/secret-stack')) + .use(require('ppppp-db')) + .use(require('ssb-box')) + .use(require('../lib')) + .call(null, { + caps, + connections: { + incoming: { + net: [{ scope: 'device', transform: 'shse', port: null }], + }, + outgoing: { + net: [{ transform: 'shse' }], + }, + }, + ...opts, + }) +} + +module.exports = { createPeer } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bd2acd5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["lib/**/*.js"], + "exclude": ["coverage/", "node_modules/", "test/"], + "compilerOptions": { + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["es2022", "dom"], + "module": "node16", + "skipLibCheck": true, + "strict": true, + "target": "es2021" + } +} \ No newline at end of file