Skip to content

Refactor activity log types #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 16 additions & 17 deletions main/activity-log.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,46 @@
'use strict'

/** @typedef {import('./typings').ActivityEvent} ActivityEvent */
/** @typedef {import('./typings').ActivityEntry} ActivityEntry */
/** @typedef {import('./typings').Activity} Activity */
/** @typedef {import('./typings').RecordActivityOptions} RecordActivityOptions */
Copy link
Member Author

@juliangruber juliangruber Sep 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify the types by thinking about them this way: One is the actual activity (Activity), the other is the options passed to .recordActivity (RecordActivityOptions). To me this is clearer than having to juggle Activity, Event and Entry in your mind, and the variable names are nicely consistent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also rename this to use Event instead of Activity, which is the more common term, but I do think here Activity works better, because it's clearer that we're referring to a product concept, and not a generic event.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea to user Activity to describe the data structure we are storing in our log 👍🏻

I have mixed feelings about the name RecordActivityOptions. In my mind, options is for optional configuration tweaking the behaviour of a function. How about something like RecordActivityFields or NewActivityProps?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I tried to look up prior art on this and found that the types for node for example call arguments options but their type ...Args:

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3b2cb0f873d4caca3e4fc01aeaf9f8ec40852d8f/types/node/http.d.ts#L138-L139

Either would work for me 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use NewActivityArgs then.


const Store = require('electron-store')
const crypto = require('node:crypto')

const activityLogStore = new Store({
name: 'activity-log'
})

class ActivityLog {
#entries
#lastId

constructor () {
this.#entries = loadStoredEntries()
this.#lastId = Number(this.#entries.at(-1)?.id ?? 0)
}

/**
* @param {ActivityEvent} args
* @returns {ActivityEntry}
* @param {RecordActivityOptions} args
* @returns {Activity}
*/
recordEvent ({ source, type, message }) {
const nextId = ++this.#lastId
/** @type {ActivityEntry} */
const entry = {
id: String(nextId),
recordActivity ({ source, type, message }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a method of ActivityLog class, maybe we can shorten the name to simply record?

Suggested change
recordActivity ({ source, type, message }) {
record ({ source, type, message }) {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea!

/** @type {Activity} */
const activity = {
id: crypto.randomUUID(),
timestamp: Date.now(),
source,
type,
message
}
// Freeze the data to prevent ActivityLog users from accidentally changing our store
Object.freeze(entry)
Object.freeze(activity)

this.#entries.push(entry)
this.#entries.push(activity)

if (this.#entries.length > 100) {
// Delete the oldest entry to keep ActivityLog at constant size
// Delete the oldest activity to keep ActivityLog at constant size
this.#entries.shift()
}
this.#save()
return entry
return activity
}

getAllEntries () {
Expand All @@ -56,12 +55,12 @@ class ActivityLog {
}

#save () {
activityLogStore.set('events', this.#entries)
activityLogStore.set('activities', this.#entries)
}
}

/**
* @returns {ActivityEntry[]}
* @returns {Activity[]}
*/
function loadStoredEntries () {
// A workaround to fix false TypeScript errors
Expand Down
18 changes: 9 additions & 9 deletions main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const { setupAppMenu } = require('./app-menu')
const { ActivityLog } = require('./activity-log')
const { ipcMain } = require('electron/main')

/** @typedef {import('./typings').ActivityEvent} ActivityEvent */
/** @typedef {import('./typings').ActivityEntry} ActivityEntry */
/** @typedef {import('./typings').Activity} Activity */
/** @typedef {import('./typings').RecordActivityOptions} RecordActivityOptions */

const inTest = (process.env.NODE_ENV === 'test')
const isDev = !app.isPackaged && !inTest
Expand Down Expand Up @@ -94,18 +94,18 @@ const activityLog = new ActivityLog()
let isActivityStreamFlowing = false

/**
* @param {ActivityEvent} event
* @param {RecordActivityOptions} opts
*/
function recordActivity (event) {
const entry = activityLog.recordEvent(event)
if (isActivityStreamFlowing) emitActivity(entry)
function recordActivity (opts) {
const activity = activityLog.recordActivity(opts)
if (isActivityStreamFlowing) emitActivity(activity)
}

/**
* @param {ActivityEntry} entry
* @param {Activity} activity
*/
function emitActivity (entry) {
ipcMain.emit(ipcMainEvents.ACTIVITY_LOGGED, entry)
function emitActivity (activity) {
ipcMain.emit(ipcMainEvents.ACTIVITY_LOGGED, activity)
}

function resumeActivityStream () {
Expand Down
4 changes: 2 additions & 2 deletions main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ contextBridge.exposeInMainWorld('electron', {
resumeActivityStream: () => ipcRenderer.invoke('station:resumeActivityStream'),

/**
* @param {(activityEntry: import('./typings').ActivityEntry) => void} callback
* @param {(Activity: import('./typings').Activity) => void} callback
*/
onActivityLogged (callback) {
ipcRenderer.on('station:activity-logged', (_event, entry) => callback(entry))
ipcRenderer.on('station:activity-logged', (_event, activity) => callback(activity))
},

saturnNode: {
Expand Down
32 changes: 16 additions & 16 deletions main/test/activity-log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,47 @@ const assert = require('assert').strict
const { ActivityLog } = require('../activity-log')
const { assertTimestampIsCloseToNow, pickProps } = require('./test-helpers')

/** @typedef {import('../typings').ActivityEvent} ActivityEvent */
/** @typedef {import('../typings').RecordActivityOptions} RecordActivityOptions */

describe('ActivityLog', function () {
beforeEach(function () { return ActivityLog.reset() })

it('record events and assign them timestamp and id ', function () {
it('record activities and assign them timestamp and id ', function () {
const activityLog = new ActivityLog()
const entryCreated = activityLog.recordEvent(givenActivity({
const activityCreated = activityLog.recordActivity(givenActivity({
source: 'Station',
type: 'info',
message: 'Hello world!'
}))

assert.strictEqual(activityLog.getAllEntries().length, 1)
assert.deepStrictEqual(entryCreated, activityLog.getAllEntries()[0])
assert.deepStrictEqual(activityCreated, activityLog.getAllEntries()[0])

const { timestamp, ...entry } = activityLog.getAllEntries()[0]
assert.deepStrictEqual(entry, {
const { timestamp, ...activity } = activityLog.getAllEntries()[0]
assert.deepStrictEqual(activity, {
id: '1',
source: 'Station',
type: 'info',
message: 'Hello world!'
})

assertTimestampIsCloseToNow(timestamp, 'event.timestamp')
assertTimestampIsCloseToNow(timestamp, 'activity.timestamp')
})

it('assigns unique ids', function () {
const activityLog = new ActivityLog()
activityLog.recordEvent(givenActivity({ message: 'one' }))
activityLog.recordEvent(givenActivity({ message: 'two' }))
activityLog.recordActivity(givenActivity({ message: 'one' }))
activityLog.recordActivity(givenActivity({ message: 'two' }))
assert.deepStrictEqual(activityLog.getAllEntries().map(it => pickProps(it, 'id', 'message')), [
{ id: '1', message: 'one' },
{ id: '2', message: 'two' }
])
})

it('preserves events across restarts', function () {
new ActivityLog().recordEvent(givenActivity({ message: 'first run' }))
it('preserves activities across restarts', function () {
new ActivityLog().recordActivity(givenActivity({ message: 'first run' }))
const activityLog = new ActivityLog()
activityLog.recordEvent(givenActivity({ message: 'second run' }))
activityLog.recordActivity(givenActivity({ message: 'second run' }))
assert.deepStrictEqual(activityLog.getAllEntries().map(it => pickProps(it, 'id', 'message')), [
{ id: '1', message: 'first run' },
{ id: '2', message: 'second run' }
Expand All @@ -56,19 +56,19 @@ describe('ActivityLog', function () {

const log = new ActivityLog()
for (let i = 0; i < 110; i++) {
log.recordEvent(givenActivity({ message: `event ${i}` }))
log.recordActivity(givenActivity({ message: `activity ${i}` }))
}
const entries = log.getAllEntries()
assert.deepStrictEqual(
[entries.at(0)?.message, entries.at(-1)?.message],
['event 10', 'event 109']
['activity 10', 'activity 109']
)
})
})

/**
* @param {Partial<ActivityEvent>} [props]
* @returns {ActivityEvent}
* @param {Partial<RecordActivityOptions>} [props]
* @returns {RecordActivityOptions}
*/
function givenActivity (props) {
return {
Expand Down
17 changes: 10 additions & 7 deletions main/typings.d.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
export type ActivitySource = 'Station' | 'Saturn';
export type ActivityEventType = 'info' | 'error';
export type ActivityType = 'info' | 'error';

export interface ActivityEvent {
type: ActivityEventType;
export interface Activity {
id: string;
timestamp: number;
type: ActivityType;
source: ActivitySource;
message: string;
}

export interface ActivityEntry extends ActivityEvent {
id: string;
timestamp: number;
export interface RecordActivityOptions {
type: ActivityType;
source: ActivitySource;
message: string;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the inheritance, because Activity is the main type, and RecordActivityOptions more a partial of it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense to remove inheritance 👍🏻

An alternative approach that does not duplicate property definitions:

export type RecordActivityOptions = Pick<Activity, 'type' | 'source' | 'message'>;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool I like that!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the second thought, this is probably more future proof when it comes to adding new Activity props:

export type RecordActivityOptions = Omit<Activity, 'id' | 'timestamp'>;

}

export interface Context {
recordActivity(event: ActivityEvent): void;
recordActivity(activity: RecordActivityOptions): void;
resumeActivityStream(): void;

showUI: () => void
Expand Down
19 changes: 17 additions & 2 deletions renderer/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
import { Activity } from '../../main/typings'

ReactDOM.createRoot(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -15,8 +16,22 @@ ReactDOM.createRoot(
</React.StrictMode>
)

window.electron.onActivityLogged(entry => {
console.log('[ACTIVITY] %j', entry)
const activities: Activity[] = []

window.electron.onActivityLogged(activity => {
activities.push(activity)
// In case two activities were recorded in the same millisecond, fall back to
// sorting by their IDs, which are guaranteed to be unique and therefore
// provide a stable sorting.
activities.sort((a, b) => {
return a.timestamp !== b.timestamp
? b.timestamp - a.timestamp
: a.id.localeCompare(b.id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure ids created by crypto.randomUUID() monotonically increasing over time?

Maybe the order of events reported in the same millisecond does not matter and that's why we can fallback our sorting comparisons to comparing ids?

I think it would be great to add a code comment to clarify this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't increase, and that's not an issue in my mind. As long as we sort deterministically, no human will have noticed that the events from the same millisecond have been mixed up. And we sort deterministically by giving events IDs when storing them. I'll add a comment 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternative idea: Is there a timing api with such precision that it's guaranteed that two successive calls won't get the same values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.hrtime() seems to work:

> [process.hrtime(), process.hrtime()]
[ [ 845635, 876114697 ], [ 845635, 876117208 ] ]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we sort deterministically, no human will have noticed that the events from the same millisecond have been mixed up.

Unless these two events happen to be something like "Starting the station" and "Starting Saturn module". It would look weird to have them in the reverse direction.

Anyhow, I think this does not really matter, especially not now.

})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we moved sorting to the backend, then we wouldn't have to sort on every new event. Otherwise I think we could do it in either location, and having it on the view layer might be easier to get right wrt race conditions

if (activities.length > 100) activities.shift()

console.log('[ACTIVITY] %j', activity)
console.log('[ACTIVITIES]', activities)
})

window.electron.resumeActivityStream().then(() => {
Expand Down
6 changes: 3 additions & 3 deletions renderer/src/typings.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ActivityEntry } from '../main/typings'
import { Activity } from '../main/typings'

export declare global {
interface Window {
electron: {
resumeActivityStream(): Promise<ActivityEntry[]>,
onActivityLogged(callback: (activityEntry: ActivityEntry) => void),
resumeActivityStream(): Promise<Activity[]>,
onActivityLogged(callback: (activity: Activity) => void),

saturnNode: {
start:() => Promise<void>,
Expand Down