offline-first, p2p synced, atproto enabled, feed reader
1import {Semaphore} from './async/semaphore'
2
3/** extension of readonly maps that has a throwing require */
4export interface ReadonlyStrictMap<K, V> extends ReadonlyMap<K, V> {
5 require(key: K): V
6}
7
8/** a map with methods to ensure key presence and safe update */
9export class StrictMap<K, V> extends Map<K, V> {
10 #lock = new Semaphore(1)
11
12 /**
13 * get a value from the map, throwing if missing
14 * @throws Error if the key is not present in the map
15 */
16 require(key: K): V {
17 if (!this.has(key)) throw Error(`key is required but not in the map`)
18
19 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
20 const value = this.get(key)!
21 return value
22 }
23
24 /**
25 * remove a value from the map, returning it if it was present.
26 * @param key - the key to delete
27 * @returns the value at the given key before removal
28 */
29 remove(key: K): V | undefined {
30 const peer = this.get(key)
31 this.delete(key)
32 return peer
33 }
34
35 /** get a value from the map, creating it if not present */
36 ensure(key: K, maker: () => V): V {
37 if (!this.has(key)) {
38 this.set(key, maker())
39 }
40
41 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
42 return this.get(key)!
43 }
44
45 /** get a value from the map, creating it (async) if not present */
46 async ensureAsync(key: K, maker: (signal?: AbortSignal) => Promise<V>, signal?: AbortSignal): Promise<V> {
47 signal?.throwIfAborted()
48
49 if (!(await this.#lock.take(signal))) {
50 throw new Error('lock take aborted')
51 }
52
53 try {
54 if (!this.has(key)) {
55 const value = await maker(signal)
56 if (!this.has(key)) {
57 this.set(key, value)
58 } else {
59 console.warn('ensureasync was out-raced!', this, key)
60 }
61 }
62 } finally {
63 this.#lock.free()
64 }
65
66 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
67 return this.get(key)!
68 }
69
70 /** update a value in the map, removing if undefined is returned */
71 update(key: K, update: (prev?: V) => V | undefined): V | undefined {
72 const prev = this.get(key)
73 const next = update(prev)
74
75 if (next === undefined) {
76 this.delete(key)
77 } else {
78 this.set(key, next)
79 }
80
81 return this.get(key)
82 }
83
84 /** update a value in the map, removing if undefined is returned */
85 async updateAsync(
86 key: K,
87 update: (prev?: V, signal?: AbortSignal) => Promise<V | undefined>,
88 signal?: AbortSignal,
89 ): Promise<V | undefined> {
90 signal?.throwIfAborted()
91
92 if (!(await this.#lock.take(signal))) {
93 throw new Error('lock take aborted')
94 }
95
96 try {
97 const prev = this.get(key)
98 const next = await update(prev, signal)
99
100 if (prev === this.get(key)) {
101 if (next === undefined) {
102 this.delete(key)
103 } else {
104 this.set(key, next)
105 }
106 } else {
107 // current value !== value we updated
108 console.warn('we were out raced!', this, key)
109 }
110 } finally {
111 this.#lock.free()
112 }
113
114 return this.get(key)
115 }
116}