forked from sheepzh/time-tracker-4-browser
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtimeline-database.ts
More file actions
140 lines (121 loc) · 5.3 KB
/
Copy pathtimeline-database.ts
File metadata and controls
140 lines (121 loc) · 5.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time'
import {
BaseIDBStorage, iterateCursor, req2Promise, type Index, type IndexResult, type Key, type Table,
} from './common/indexed-storage'
type TimelineCondition = {
host?: string
/**
* Start time in milliseconds, inclusive
*/
start?: number
}
const TIME_LIFE_CYCLE = MILL_PER_DAY * 366
// If two tick with the same host is near 1 sec, then merge them to one
const MERGE_THRESHOLD = MILL_PER_SECOND
const canMerge = (exist: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => {
const { start: existStart, host: existHost } = exist
const { start, host } = tick
return existHost === host && start >= existStart && start <= existStart + MERGE_THRESHOLD
}
const isConflict = (item: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => {
const { start: itemStart, duration: itemDuration } = item
const { start } = tick
return itemStart <= start && start < itemStart + itemDuration
}
type IndexCoverage = {
host?: boolean
start?: boolean
}
class CleanThrottle {
private lastTime = 0
private readonly interval: number = MILL_PER_DAY
tryClean(doClean: () => void): void {
const now = Date.now()
if (now - this.lastTime >= this.interval) {
this.lastTime = now
doClean()
}
}
}
class TimelineDatabase extends BaseIDBStorage<tt4b.timeline.Tick> {
indexes: Index<tt4b.timeline.Tick>[] = [
'host', 'start',
]
key: Key<tt4b.timeline.Tick> | Key<tt4b.timeline.Tick>[] = ['host', 'start']
table: Table = 'timeline'
private cleanThrottle = new CleanThrottle()
batchSave(ticks: tt4b.timeline.Tick[]): Promise<void> {
return this.withStore(async store => {
const index = this.assertIndex(store, 'host')
const hosts = Array.from(new Set(ticks.map(tick => tick.host)))
// Fetch existing records for all hosts
const existByHost = new Map<string, tt4b.timeline.Tick[]>()
await Promise.all(hosts.map(async host => {
const req = index.getAll(IDBKeyRange.only(host))
const exist = await req2Promise<tt4b.timeline.Tick[]>(req)
exist && existByHost.set(host, exist)
}))
const toSave: tt4b.timeline.Tick[] = []
const toDelete: tt4b.timeline.Tick[] = []
ticks.forEach(tick => {
const existForHost = existByHost.get(tick.host) ?? []
// Check if there's any conflict
const anyConflict = existForHost.some(exist => isConflict(exist, tick))
if (anyConflict) return
// Find a record that can be merged
const mergeTarget = existForHost.find(exist => canMerge(exist, tick))
if (mergeTarget) {
toDelete.push(mergeTarget)
const { host, start, duration } = tick
const newStart = Math.min(mergeTarget.start, start)
const newEnd = Math.max(mergeTarget.start + mergeTarget.duration, start + duration)
const newDuration = newEnd - newStart
toSave.push({ host, start: newStart, duration: newDuration })
} else {
// No conflict and no merge, save the new tick
toSave.push(tick)
}
})
toDelete.forEach(tick => store.delete([tick.host, tick.start]))
toSave.forEach(tick => store.put(tick))
}, 'readwrite')
}
async select(cond?: TimelineCondition): Promise<tt4b.timeline.Tick[]> {
const rows = await this.withStore(async store => {
const { cursorReq, coverage = {} } = this.judgeIndex(store, cond)
const rows = await iterateCursor<tt4b.timeline.Tick>(cursorReq)
const { start: cs, host: ch } = cond ?? {}
return rows.filter(tick => {
const { host, start } = tick
if (cs && !coverage.start && start < cs) return false
if (ch && !coverage.host && host !== ch) return false
return true
})
}, 'readonly')
// Cleanup outdated ticks periodically
this.cleanThrottle.tryClean(() => this.withStore(store => {
const index = this.assertIndex(store, 'start')
const req = index.openCursor(IDBKeyRange.upperBound(Date.now() - TIME_LIFE_CYCLE, true))
iterateCursor(req, cursor => { cursor.delete() })
}, 'readwrite').catch(e => console.error('Failed to cleanup outdated ticks', e)))
return rows
}
private judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult<IndexCoverage> {
const { host, start } = cond ?? {}
if (host) {
return {
cursorReq: this.assertIndexCursor(store, 'host', IDBKeyRange.only(host)),
coverage: { host: true },
}
} else if (start !== undefined && start > 0) {
return {
cursorReq: this.assertIndexCursor(store, 'start', IDBKeyRange.lowerBound(start, false)),
coverage: { start: true },
}
} else {
return { cursorReq: store.openCursor() }
}
}
}
const timelineDatabase = new TimelineDatabase()
export default timelineDatabase