SMS History Synchronisation
\Jethro\Sms\synchronizeHistory() (include/jethro_sms.php) imports SMS
history from the upstream provider into the local sms + smsdelivery
tables. It is invoked from the admin status panel ("Synchronize History"
operation), ?call=admin_sms_sync_history, and scripts/sms.php sync-history. checkSynchronized() is the read-only diff companion.
Design: stage, refine, compare, insert
Upstream deliveries ($provider->listRecentDeliveries($since)) are staged
verbatim into two scratch tables, then progressively refined with SQL
until they can be compared against the real tables to identify duplicates.
Nothing touches sms/smsdelivery until the final step.
Staging tables:
| Table | Mirrors | Extra working columns |
|---|---|---|
smsdelivery_new | smsdelivery | phone_intl (international digits), grp (session number), batch_id (→ sms_new.id), sms_id (resolved target) |
sms_new | sms | grp, sms_id (matching existing sms.id, if any) |
Pipeline:
- Stage the feed verbatim (multi-row inserts;
phone_intlcomputed viaPhoneNumber::internationalise('0', '61')). - Group into batches: gap-based sessions per message body — a >1-hour
silence in same-body deliveries starts a new batch (window functions:
LAG+ runningSUM). Onesms_newrow per (body, session); batchcreated= earliest send time. - Collapse the feed to one record per delivery:
ROW_NUMBER()perremote_id, and per (batch, recipient phone). Handles pagination overlap and multiple report entries per recipient (multipart segments, 'sent' followed by 'delivered'). The survivor is the most final status, preferring records with a delivery time. - Resolve
personidfromphone_intlagainst_person.mobile_tel(both sides normalised to international digits in SQL). - Match batches to existing
smsrows: same body,createdwithin ±10 minutes (closest wins). Existing rows may predateremote_idlogging, so body+time is the only batch identity available. - Delete staged duplicates of existing
smsdeliveryrows under the matched batch: byremote_idwhen both sides have one, else by recipient phone (viapersonid→_person.mobile_tel, or theraw_responseJSON$.destination). - Insert: new
smsrows for unmatched batches that still have deliveries, then one bulkINSERT … SELECTof the surviving staged deliveries. New deliveries for a partially-imported batch attach to the existingsmsrow.
Returns {deleted, imported, batches, skipped}: staged rows discarded as
local duplicates; smsdelivery rows inserted; sms rows created; upstream
batches already fully present.
Portability notes
The SQL is deliberately portable to stock MySQL 8+ as well as MariaDB
(window functions, REGEXP_REPLACE, FIELD, GET_LOCK, JSON_VALID).
Consequences of that choice:
- The scratch tables are real tables, not
TEMPORARY— MySQL cannot reference a temporary table more than once in a query, and the refinement steps self-join andUPDATE … JOIN (SELECT … FROM same_table)the staging tables. They are dropped in afinally. - Concurrent syncs are serialised with
GET_LOCK(CONCAT(DATABASE(), '.sms_sync'), 5)since the scratch tables are shared; a second concurrent run throws. CREATE/DROP TABLEimplicitly commits, so all DDL runs before the function opens its own transaction (only when none is active,PDO::inTransaction()), and callers must not invoke it inside a transaction whose atomicity they need.JSON_EXTRACTon theraw_responsedestination is guarded byJSON_VALID— MySQL errors on invalid JSON where MariaDB returns NULL, and legacyraw_responsevalues may not be JSON.- Collations vary across instances (
sms.bodyisutf8mb4_unicode_ci; other columns commonlyutf8mb4_uca1400_ai_ciorutf8mb4_general_ci). Staging tables are createdutf8mb4_unicode_ci, and every cross-table string comparison carries an explicitCOLLATE utf8mb4_unicode_ci(orCONVERT … USING utf8mb4for computed phone expressions) so it never depends on the instance's table collations.
Tests
tests/sms/bridge/test_synchronize_history_dedup.php — runs against the
real DB with a fake provider injected into the getSmsProvider() memo.
Because the function's DDL implicitly commits, the tests cannot run in a
rolled-back transaction; rows use unique bodies and are deleted by a
register_shutdown_function (the test() helper only registers —
run_all() executes later, so file-end cleanup code would fire too
early). The bootstrap sets $_SERVER['HTTP_HOST'] from the instance
directory name so conf.php routes to the right account from the CLI.