Fit every concatenated segment in an SMS, and close the encoding helpers' throw (#96)

* Regression tests for the concatenated segment budget and the encoding-name door

* Budget unpacked segments at 134 octets, and export isEncodingName

* Name the length a Latin-1 message starts costing more segments at

* Fold the totality clause into hard rule 1, and log the unrepresentable-alphabet finding

* Make the maxSegments regression discriminate, and drop the narrating comments

* State the per-alphabet capacities accurately, and log the invalid-date path
This commit is contained in:
2026-09-09 17:21:51 +02:00
committed by GitHub
parent ff6ba9f825
commit 7d6039796d
7 changed files with 131 additions and 30 deletions
+35 -6
View File
@@ -52,7 +52,9 @@ These are not preferences. Breaking one is a defect.
1. **Nothing throws.** Every fallible function returns (or resolves to) a DTO carrying an optional
`err`. No `throw`, no rejected promises, no exceptions as control flow. Node APIs that throw are
wrapped at the boundary and converted into a result. Programmer errors (bad arguments) are
results too.
results too, wherever the types admit one: a function whose argument types are a closed set is
guarded by the compiler and stays total, which is why the encoding helpers return plainly, and
the check belongs at whichever boundary the argument arrives untyped at.
2. **Log messages are static strings.** Every dynamic value goes into the log metadata. Never
interpolate, never concatenate.
- GOOD: `log.debug('sendSms() - splitting message', { parts: msgs.length, to });`
@@ -182,12 +184,12 @@ so a peer that dispatches one request at a time is never left waiting on us.
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
septets. The 140-octet limit applies to that packed result, not to what goes on the wire here, which
is why a concatenated segment is 153 characters plus a 6-octet UDH — 159 octets in `short_message`,
and entirely correct. Do not "fix" this to 134; that number is the packed payload size and would
truncate every long message by a fifth.
is why a concatenated GSM segment is 153 characters plus a 6-octet UDH — 159 octets in
`short_message`, and entirely correct. Do not "fix" that to 134; that number is the packed payload
size and would truncate every long GSM message by a fifth.
UCS2 is not packed, so there the two coincide: 67 characters = 134 octets, plus the 6-octet UDH is
exactly 140.
GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a
decision under [The wire](#the-wire).
## Conventions
@@ -301,6 +303,21 @@ Grouped by what each one constrains.
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`.
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
every member of the union has a codec and one added without a codec, or without a segment budget,
fails to compile in four places. What was missing is the door for a caller holding a name at
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it —
to guard a state the compiler refuses. Where the domain really is open the check is already there:
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
without types gets.
### The wire
- **The declared interface version is an option on both `client()` and `server()`, and is not the
@@ -557,6 +574,18 @@ Grouped by what each one constrains.
the base instead would break that pair. The option is on `client()` only, since a `server()` session
writes both ids itself.
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
### The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,