Plan the comprehension rewrite and file its background
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
This commit was merged in pull request #50.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# Plan 1: the application developer's mental model as the source tree
|
||||
|
||||
Lens: an application developer thinks in six verbs: *connect* (client), *listen* (server), *send*,
|
||||
*receive*, *get a report*, *shut down*, plus *read PDUs* when they go low-level. Every directory is
|
||||
one of those verbs, in the README's order, and every boundary between them is a seam a developer
|
||||
already knows exists.
|
||||
|
||||
## 1. Principles
|
||||
|
||||
1. **The README's table of contents is the directory listing.** `Send an SMS` → `sending/`,
|
||||
`Delivery reports` → `receipts/`, `Receive SMS` → `receiving/`, `Run an SMPP server` → `server/`,
|
||||
`Client options`/reconnect → `client/`, `Session` → `session/`, `PDUs and the low-level API` →
|
||||
`wire/`, `Encoding`/long messages → `text/`. Answers: Navigation stuck at 6 in every round, while
|
||||
C, the one draft with grouped folders, reached Locality 6 on every seat.
|
||||
2. **One socket, one `Session`, one life.** A `Session` goes `open → bound → closing → closed` and
|
||||
never backwards. Reconnect is a separate `ClientSession` above it, whose state is a discriminated
|
||||
union holding a `Session` only while one is up. Answers lesson 3: `LinkLife`'s phase plus
|
||||
`stopped`, seven predicates, call-order-dependent `linkLost`/`end`/`dropSocket`, and
|
||||
`ReconnectLoop`'s duplicate stopped flag. F showed that the split removes the unit; this plan keeps
|
||||
F's split and fixes what F left, below.
|
||||
3. **Every invariant has one owner, and that owner's file states it.** "Every inbound request gets
|
||||
exactly one answer" → `session/owed-answer.ts`. "Nothing held without a bound" →
|
||||
`limits/bounded-store.ts`. "A send that never reached a socket may go out on the next one" →
|
||||
`client/next-link.ts`. Answers round three's "enforced jointly by `IncomingRequests` and
|
||||
`sms.ts`", and D's "answered in three places".
|
||||
4. **Lower areas never call up; they declare the port they need.** `receiving/`, `receipts/` and
|
||||
`sending/` export functions and classes that take a narrow port type *declared in their own
|
||||
file* (`AnswerPort`, `SendPort`, `report(err)`). `Session` implements the ports. Answers finding 4:
|
||||
`IncomingRequests`/`HeldMessages` calling `emit`, `sendReturn`, `close` and `listenerCount` on
|
||||
`Session`.
|
||||
5. **A store enforces its own bound.** `BoundedStore` evicts, expires and weighs by itself, never
|
||||
evicts the entry being admitted, keeps reads pure, and reports every removal through one
|
||||
`onRemoved(key, value, reason)`. Answers round three's most-cited unit: `ExpiringGroups` plus
|
||||
`Reassembler.trim`.
|
||||
6. **A state change's effects sit inside the transition that causes them.** No reducer that returns
|
||||
effects, and no callbacks wired from another file. Answers E, whose state machine scored no better
|
||||
because each transition's meaning was split over five callbacks.
|
||||
7. **Every SMPP term is glossed once, and every spec citation carries its half-line summary.**
|
||||
`SMPP 3.4 5.2.12 (esm_class: message type and mode bits)`, never a bare `5.2.12`. The README gets
|
||||
a glossary table (ESME, SMSC/MC, PDU, bind, esm_class, data_coding, UDH, sar_*, TLV, receipt,
|
||||
segment). Answers lesson: juniors score Self-sufficiency 5 because of bare citations and
|
||||
`data_coding` bit masks.
|
||||
8. **Every default in one file.** `src/defaults.ts`, grouped by the verb that uses them. Answers
|
||||
"defaults spread over several files".
|
||||
9. **Hard parts are marked by one convention.** A hard part opens with an `Invariant:` paragraph at
|
||||
the code it guards, which the comment rules permit, and AGENTS.md gets a "Where it is hard" list
|
||||
naming the file of each. The panel's "7" asks for hard parts that are few, localized and marked.
|
||||
|
||||
## 2. Layout
|
||||
|
||||
Dependencies point downward in this order: `wire` ← `text` ← `limits` ← {`sending`, `receiving`,
|
||||
`receipts`} ← `session` ← {`client`, `server`} ← `index.ts`. Root files are importable by all.
|
||||
The only ways up are ports: types declared low, implemented by `session/`.
|
||||
|
||||
```
|
||||
src/ fan-out 13: 4 files, 9 areas
|
||||
index.ts Public surface, named exports only. Grouped by the README's sections.
|
||||
defaults.ts Every default value, grouped by client/server/session/stores. No logic.
|
||||
log.ts SmppLog, silentLog, guardedLog.
|
||||
result.ts Result<T>, VoidResult, errorFrom(), namedValue(), UnansweredError.
|
||||
|
||||
wire/ "PDUs and the low-level API". Fan-out 6 + tables/
|
||||
pdu.ts pduToObj/objToPdu/pduReturn/isCommand/isResp. Stateless, total.
|
||||
framer.ts PduFramer: byte stream → complete PDUs. State: the partial buffer.
|
||||
refusal.ts PduRefusedError, PduHeader, the status SMPP names for each refusal.
|
||||
retained-pdu.ts detach() and retainedOctets(): what holding a PDU costs.
|
||||
smpp-time.ts smppDate/smppTime: the 16-char time format, absolute and relative.
|
||||
tables/ The spec, as data. Fan-out 7. Knows nothing above it but result.ts.
|
||||
commands.ts 33 commands, ids, params in WIRE ORDER (invariant stated at the top).
|
||||
constants.ts consts, constsById, interface versions.
|
||||
alphabets.ts GSM 03.38 (named gsm7 internally; 'ASCII' only as the public name), LATIN1, UCS2 codecs; detection.
|
||||
data-coding.ts data_coding → alphabet and message class; one row per coding group with its meaning in words.
|
||||
errors.ts ESME_* by numeric id.
|
||||
tlvs.ts TLV table by numeric id; typed read/write of a TLV stream.
|
||||
types.ts Wire types int8…cstring, arrays.
|
||||
index.ts defs, the grouped tables.
|
||||
|
||||
text/ "Encoding" and "Long messages". Fan-out 3. Pure functions.
|
||||
message-text.ts encode/decode/bitCount/unencodable; where a PDU's body is (short_message vs message_payload).
|
||||
splitting.ts splitMessage and segment budgets (153 GSM unpacked / 134 / 67); the "GSM is sent unpacked" note lives here.
|
||||
concatenation.ts Both spellings, both directions: UDH build/parse, sar_* read, concatOf(), ConcatReference counter.
|
||||
|
||||
limits/ What is bounded, and waiting on it. Fan-out 3.
|
||||
bounded-store.ts BoundedStore<V>: count cap, weight cap, expiry, one sweep timer, onRemoved(key, v, reason). Invariant: admit never evicts its own key; get() never mutates.
|
||||
send-window.ts SendWindow: the maxOutstanding semaphore; idle(timeout) for the drain.
|
||||
idle-waiters.ts Waiting for a count to reach zero within a budget.
|
||||
|
||||
sending/ "Send an SMS". Fan-out 2.
|
||||
compose-submit.ts submit_sm params from SendSmsOptions: TON, messaging mode, flash, times, the alphabet check. Pure.
|
||||
sms-sender.ts SmsSender: split, send every segment together through a SendPort, collect ids, tell the merger. State: ConcatReference, ReceiptMerger.
|
||||
|
||||
receipts/ "Delivery reports". Fan-out 5.
|
||||
receipt-states.ts message_state ↔ stat: codes, FAILED/DELIVERD aliases, which states are transient. Invariant: only ENROUTE/SCHEDULED are not final.
|
||||
read-receipt.ts dlrFromPdu(), parseReceipt(): esm_class, then TLV, then body.
|
||||
write-receipt.ts The deliver_sm a sendDlr() sends: text, TLVs, esm_class 0x04 vs 0x20. Pure.
|
||||
receipt-merger.ts ReceiptMerger: per-segment receipts into one MessageDlr. State: open groups, spent bases (BoundedStore x2). `close` renamed `spend`.
|
||||
message-ids.ts uuidv7(), <base>-<n>, smsIdFormat normalisation, which response carries an id.
|
||||
|
||||
receiving/ "Receive SMS" and "Receiving in depth". Fan-out 4.
|
||||
receive-message.ts The one path an inbound message takes: bound check → concat? → reassemble → answer segment → hand whole message to handlers. Takes AnswerPort per request.
|
||||
reassembler.ts Reassembler: incomplete groups in a BoundedStore; lost groups reported once.
|
||||
running-handlers.ts RunningHandlers: onSms calls in flight. State: count, weight, deadline per call. Invariant: return releases, throw refuses with the retry status, no handler refuses at once. Its SendPort lets a running handler's sendDlr pass the drain.
|
||||
sms.ts The Sms handle: fields decoded once; sendResp → the message's OwedAnswers; sendDlr → write-receipt via SendPort.
|
||||
|
||||
session/ "Session". One socket's life. Fan-out 8.
|
||||
session.ts Session (public): state 'open'|'bound'|'closing'|'closed' in one field; bound(), send, sendSms, sendReturn, close, unbind; emits. Each transition method carries its own effects.
|
||||
session-options.ts SessionOptions type and its checks.
|
||||
transport.ts One socket + framer + write(). No attach(): a socket never changes.
|
||||
heartbeat.ts enquire_link on quiet, idle timeout. State: two timers.
|
||||
requests.ts Outgoing: seqNr, pending map, response timeout, window slot, abort, UnansweredError. Merges pending-requests + outgoing-requests minus link logic.
|
||||
dispatch.ts An inbound PDU's route: response → requests; request → onRequest hook → bind direction → enquire_link/unbind/re-bind/unknown/message/receipt.
|
||||
owed-answer.ts OwedAnswer: created for every inbound request at dispatch; the only writer of a response. State: owed|answered|lost. Implements AnswerPort.
|
||||
bind-direction.ts What each bind type carries per link end; data_sm stands in for submit_sm or deliver_sm; checkedBind().
|
||||
shutdown.ts The drain: handlers first, then requests, one deadline, the err naming what was lost.
|
||||
|
||||
client/ "Client options" and reconnect. Fan-out 5.
|
||||
client.ts client(), ClientSession (public): state {kind:'connecting'} | {kind:'up', session} | {kind:'down', attempt} | {kind:'closed'}. Forwards events of the current Session; owns SmsSender so merges survive a reconnect.
|
||||
client-options.ts ClientOptions and their checks (connectTimeout, reconnect spelling, fromStart).
|
||||
connect.ts Open a socket: TCP or TLS, connectTimeout over both.
|
||||
bind.ts bind_* params, the bind response → session.bound().
|
||||
reconnect.ts Backoff as a pure function (delay, upSince) → next delay, and the retry timer. No stopped flag: the ClientSession's 'closed' state is the stop.
|
||||
next-link.ts Sends waiting for a bound Session, one budget each; a send that never reached a socket retries here. Invariant: nothing that reached a socket is resent.
|
||||
|
||||
server/ "Run an SMPP server" and "Server in depth". Fan-out 3.
|
||||
server.ts server(), SmppServer: listener, sessions set, close() = stop listening then drain each.
|
||||
server-options.ts ServerOptions and their checks.
|
||||
accept-bind.ts Pre-bind requests, authenticate, bind_resp with sc_interface_version, Session.bound().
|
||||
```
|
||||
|
||||
Deepest level is 3 (`src/wire/tables/`). Maximum fan-out 13 at `src/`, otherwise ≤ 8.
|
||||
|
||||
## 3. Public API changes
|
||||
|
||||
Three changes; everything else in the README keeps its spelling, including `client()` resolving
|
||||
`{ err, session }`, so every send and receipt example survives untouched.
|
||||
|
||||
1. **Inbound messages reach an `onSms` handler option instead of an `sms` event.**
|
||||
- Old: `session.on('sms', async sms => { await sms.sendResp(); })`, and a server's
|
||||
`smpp.on('session', s => s.on('sms', …))`.
|
||||
- New: `client({ onSms })`, `server({ onSms })`, `new Session({ onSms })`, typed
|
||||
`(sms: Sms) => Promise<void> | void`. `sms.sendResp()` stays the only way to answer, with the same
|
||||
options. Returning releases the message, answering `ESME_ROK` first if `sendResp()` was not called.
|
||||
Throwing or rejecting refuses it with the retry status (`ESME_RTHROTTLED` or `ESME_RX_T_APPN`)
|
||||
unless it is already answered, and reports it on `sessionError`. With no `onSms`, every message is
|
||||
refused at once with that retry status and one `warn` is logged. `sendDlr()` before the answer
|
||||
returns `err`. `sms.answeredOnArrival` stays.
|
||||
- Removes: the held-message timing contract (lesson 1): listener counts, the `setImmediate` turn,
|
||||
`captureRejections` routed through a `WeakMap`, and "answered" living in three places, because
|
||||
`answeredOnArrival` becomes a getter over the OwedAnswers.
|
||||
- Serves goal 2 (a crash before the answer leaves the peer to resend, unlike C) and goal 5. The
|
||||
no-handler refusal is a judgement call that goes in docs/decisions.md, resting on goal 2's "work
|
||||
the peer has no reason to send again is not dropped".
|
||||
- Migration is one line per listener: the handler body is unchanged.
|
||||
2. **A `Session` is one socket; reconnect is `ClientSession`, which `client()` returns.**
|
||||
- Old: `Session` with a `reconnect: { connect, onConnected }` option, `disconnected` and
|
||||
`reconnected` events, and `sock` swapped under it.
|
||||
- New: `Session` has no `reconnect` option, no `disconnected`/`reconnected`, and a fixed `sock`.
|
||||
`client()` still resolves `{ err, session }`, where `session` is a `ClientSession` with today's
|
||||
client methods (`sendSms`, `send`, `unbind`, `close`, `boundAs`, `peerInterfaceVersion`,
|
||||
`acceptsOptionalParams()`, `bindAllows()`) and events (`close`, `disconnected`, `reconnected`,
|
||||
`dlr`, `messageDlr`, `sessionError`, plus `data`/`incomingPdu`/`incomingPduObj` forwarded from
|
||||
the current link). `session.sock` and `session.sendReturn()` move to `session.link`, the current
|
||||
`Session` or `undefined` while down, because both belong to one socket. A hand-wired ESME that
|
||||
wants reconnect builds a new `Session` per socket.
|
||||
- Removes: `LinkLife` and its predicates, the call-order hazard, `ReconnectLoop`'s duplicate stop,
|
||||
and client.ts's `bindOn` depending on another file's ordering (lesson 3).
|
||||
- Serves goal 8 (a smaller surface, a stated scope for `Session`) and goal 4 (one stop state, so no
|
||||
path rebinds after close).
|
||||
3. **`linkEnd` becomes a readonly constructor option on `Session`** (old: a writable field
|
||||
`session.linkEnd = 'smsc'`). A field mutable after dispatch starts is a hidden state a reader has
|
||||
to chase through `bind-direction.ts`. Serves goal 4, since the bind direction decides what is
|
||||
refused.
|
||||
|
||||
Kept deliberately: `encoding: 'ASCII'` as the public name of GSM 03.38 (inherited from 0.4.0,
|
||||
MIGRATION.md relies on it). Internally the alphabet is `gsm7` everywhere, and `alphabets.ts` glosses
|
||||
the public name once. `sessionError`'s kinds stay told apart by type and message: no panel cited them.
|
||||
|
||||
## 4. Where each thing goes
|
||||
|
||||
| Now | New home |
|
||||
| --- | --- |
|
||||
| client.ts | client/client.ts (client(), fromStart), client/connect.ts (openSocket, connectTimeout), client/bind.ts |
|
||||
| server.ts | server/server.ts, server/accept-bind.ts (authenticate, pre-bind, bind_resp) |
|
||||
| session.ts | session/session.ts (state, API, emit guard); drain → session/shutdown.ts; dispatch/refuse → session/dispatch.ts |
|
||||
| sms.ts | receiving/sms.ts (handle, sendResp); receipt building → receipts/write-receipt.ts |
|
||||
| concat.ts, udh.ts | text/concatenation.ts |
|
||||
| dlr.ts | receipts/read-receipt.ts, receipts/receipt-states.ts |
|
||||
| dlr-merger.ts | receipts/receipt-merger.ts (`close` → `spend`) |
|
||||
| error-from.ts, unanswered-error.ts, result.ts | result.ts |
|
||||
| expiring-groups.ts | limits/bounded-store.ts (enforcing its own caps) |
|
||||
| held-messages.ts | receiving/running-handlers.ts |
|
||||
| idle-waiters.ts, send-window.ts | limits/ |
|
||||
| incoming-requests.ts | session/dispatch.ts (routing, bind direction, unbind, unknown) + receiving/receive-message.ts (message path, store bound) |
|
||||
| link-life.ts | deleted: the state goes to session.ts's one field; waiting for a link goes to client/next-link.ts |
|
||||
| link-timers.ts | session/heartbeat.ts |
|
||||
| log.ts, result.ts | root |
|
||||
| message.ts | text/message-text.ts, text/splitting.ts; smppDate/smppTime → wire/smpp-time.ts |
|
||||
| message-body.ts | text/message-text.ts |
|
||||
| outgoing-requests.ts, pending-requests.ts | session/requests.ts; the next-link retry → client/next-link.ts |
|
||||
| pdu.ts, pdu-framer.ts, pdu-refusal.ts, retained-pdu.ts | wire/ |
|
||||
| pdu-transport.ts | session/transport.ts, without attach() |
|
||||
| reassembly.ts | receiving/reassembler.ts; decodeSegments → text/message-text.ts |
|
||||
| reconnect-loop.ts | client/reconnect.ts |
|
||||
| send-sms.ts | sending/compose-submit.ts + sending/sms-sender.ts |
|
||||
| session-options.ts | defaults → defaults.ts; checks → each area's *-options.ts; bindCarries/standsInFor/checkedBind → session/bind-direction.ts |
|
||||
| sms-id.ts, uuid.ts | receipts/message-ids.ts |
|
||||
| defs/* | wire/tables/*; encodings.ts split into alphabets.ts and data-coding.ts |
|
||||
|
||||
Named-hard responsibilities:
|
||||
|
||||
| Responsibility | Home and owner |
|
||||
| --- | --- |
|
||||
| Held messages and answering | session/owed-answer.ts (one answer per request); receiving/running-handlers.ts (the handler's life) |
|
||||
| Lifecycle and reconnect | session/session.ts (one-way state); client/client.ts (union state), client/reconnect.ts (backoff) |
|
||||
| The drain | session/shutdown.ts, one function; a running handler's sends admitted through its own SendPort |
|
||||
| Outgoing requests and retry | session/requests.ts (one link, no retry); client/next-link.ts (the only retry) |
|
||||
| Reassembly and ExpiringGroups | receiving/reassembler.ts over limits/bounded-store.ts |
|
||||
| Receipts and merging | receipts/ (read, states, write, merger); merger owned by SmsSender, which ClientSession holds across links |
|
||||
| The codec | wire/pdu.ts over wire/tables/ |
|
||||
| Encodings | wire/tables/alphabets.ts, wire/tables/data-coding.ts; text/ above them |
|
||||
| Defaults | src/defaults.ts |
|
||||
| Domain knowledge and glossary | README glossary table; summarised citations; the defect table and "GSM is sent unpacked" stay in AGENTS.md, with a pointer line in splitting.ts |
|
||||
|
||||
## 5. The hard parts that stay hard
|
||||
|
||||
Each is marked with an `Invariant:` paragraph in its file and listed under "Where it is hard" in
|
||||
AGENTS.md.
|
||||
|
||||
1. **Exactly one answer per inbound request** (session/owed-answer.ts). The hardness is SMPP's: a
|
||||
multipart message is answered per segment on arrival, a single one when the application says, a
|
||||
refused PDU from its header alone, and never on a link that is gone. Localized, since OwedAnswer
|
||||
is the only writer, and a test asserts no other file calls `transport.write` with a response.
|
||||
2. **The drain's order and budgets** (session/shutdown.ts). Handlers first, because a handler's
|
||||
answer can put a receipt on the wire. `shutdownTimeout: 0` still bounds the handler half. One
|
||||
function, about 40 lines, with its budget rule in its signature.
|
||||
3. **Retry only what never reached the socket** (client/next-link.ts). Goal 2's "never re-sent on
|
||||
the library's own initiative". The rule is one predicate over the `requests.ts` result
|
||||
(`written: false`), and the loop lives only here.
|
||||
4. **Bounded stores and eviction order** (limits/bounded-store.ts). The reassembly weight rule, 1000
|
||||
plus octets plus 300 per TLV, stays in receiving/reassembler.ts as one function beside its
|
||||
README-facing constant.
|
||||
5. **data_coding coding groups** (wire/tables/data-coding.ts). Bit masks are unavoidable. One table
|
||||
row per group states in words what the group means and whether it carries a class.
|
||||
6. **Receipt classification** (receipts/read-receipt.ts). esm_class, then TLV, then text, with
|
||||
operator aliases. It is operator folklore, so each alias names the operator in one line.
|
||||
|
||||
## 6. Build order
|
||||
|
||||
Each chunk keeps the suite green. Tests move to the new API only in chunks 5 and 6, the two
|
||||
contract changes.
|
||||
|
||||
1. **Tables and codec.** Move defs/ to wire/tables/, split encodings, rename gsm7 internally, and
|
||||
move pdu, framer, refusal, retained and time into wire/. Add summaries to every citation.
|
||||
2. **text/ and receipts/.** Pure moves plus `spend`. message-ids absorbs uuid.
|
||||
3. **limits/.** BoundedStore replaces ExpiringGroups, with its own tests for "admit never evicts
|
||||
itself" and "reads are pure". Reassembler, merger and held messages port onto it.
|
||||
4. **defaults.ts, the *-options.ts files, sending/.** SmsSender with its SendPort.
|
||||
5. **Contract 1: onSms.** Add owed-answer.ts, receiving/, and session/dispatch.ts. Delete
|
||||
held-messages and incoming-requests. Port the `sms` listeners in tests and README, and add the
|
||||
decision record for the no-handler refusal.
|
||||
6. **Contract 2: one-socket Session, ClientSession.** Session gets its one-way state, plus
|
||||
transport without attach, requests, heartbeat and shutdown. The client gets client/,
|
||||
next-link and reconnect. Delete link-life. Port the reconnect tests to ClientSession, and make
|
||||
`linkEnd` an option.
|
||||
7. **server/** split, index.ts regrouped by README section, the README glossary, AGENTS.md
|
||||
architecture, and "Where it is hard".
|
||||
8. **Run the comprehension panel.** Fix only what it names inside the hard-parts list.
|
||||
|
||||
## 7. Predicted panel risks
|
||||
|
||||
- **ClientSession forwarding** (client/client.ts). It re-emits the current Session's events and
|
||||
answers `boundAs` through the gap. A reader asks "which object do I listen on?" Mitigation: README
|
||||
Events table gains one column, `Session` / `ClientSession`. Likely Shape 6 on the senior's seat if
|
||||
the forwarding list is long.
|
||||
- **Two SmsSenders in client mode.** A Session inside a ClientSession has its own unused sender, and
|
||||
the ClientSession's merger is fed from the link's `dlr`. Alternative: Session takes an optional
|
||||
injected SmsSender. Pick one at chunk 6 and state it in client.ts's invariant.
|
||||
- **Ports feel like indirection to the junior.** `AnswerPort`/`SendPort` add names. Mitigation: each
|
||||
port is 1–3 members and declared in the file that uses it.
|
||||
- **Root fan-out of 13, and `limits/` is a new abstraction name.** A mid may look for the send window
|
||||
under session/. It is cross-referenced from requests.ts's constructor argument only.
|
||||
- **The no-handler refusal** surprises a transceiver client that never expected MO traffic: its SMSC
|
||||
retries forever. That is a goal-2-correct outcome, but a reader may argue it; the decision record
|
||||
must carry the argument.
|
||||
- **The coarse scale.** Removing a unit has moved the hardest unit elsewhere every round. The most
|
||||
likely next candidate is `owed-answer.ts` + `receive-message.ts`, which could hold a mean at 6.5
|
||||
rather than 7. The mitigation is to keep it the only writer and test that.
|
||||
Reference in New Issue
Block a user