Refuse what we cannot send: an alphabet that cannot carry the message, and a time nobody can read (#97)

* Regression tests for a named alphabet and a time that cannot carry the send

* Refuse a message the named alphabet cannot carry, and a time nobody can read

* Close the architecture review: a builder that cannot drop a time, and docs that hold

* Refuse a time of no known kind, and clamp a relative period to what the format holds

* Reflow the decision record and name the clamp in its test
This commit is contained in:
2026-09-09 18:16:08 +02:00
committed by GitHub
parent cced03767e
commit c06cc0648b
10 changed files with 439 additions and 68 deletions
+40 -4
View File
@@ -210,9 +210,9 @@ decision under [The wire](#the-wire).
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`
and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to
that type fails to compile in both copies at once.
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
and a field added to that type fails to compile in every copy at once.
- `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
@@ -316,7 +316,8 @@ Grouped by what each one constrains.
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.
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
### The wire
@@ -586,6 +587,41 @@ Grouped by what each one constrains.
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.
- **An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
`あいう` put `42 44 46``"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
so the guard cannot drift from what the encoder writes for any one character, and a fourth
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
binary body Latin-1 is kept for. The guard is
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
returns a `Result`, where the three encoding helpers stayed total: that argument was that
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
library's own published helper composing the garbage the guard exists to stop. Rejected: a
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
field 1 or 17 octets. Accepted: `Infinity` seconds is refused rather than clamped, where a real
period past the 99d 23:59:59 the format holds is still clamped to it. Accepted: GSM's 0x1B is an
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in.
### The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,