toga-ai 1.0.232 → 1.0.234
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/event-publish-sqs.md +60 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/monitoring-framework.md +71 -10
- package/knowledge/INDEX.md +2 -1
- package/knowledge/clients/prudential/profile.md +2 -1
- package/knowledge/clients/rate/INDEX.md +1 -0
- package/knowledge/clients/rate/features/aig-contract-creation.md +97 -0
- package/knowledge/clients/rate/profile.md +6 -2
- package/knowledge/clients/tow-foundation/features/receipt-processing.md +2 -1
- package/knowledge/registry.json +10 -0
- package/knowledge/standalone/apps/websocket/INDEX.md +6 -0
- package/knowledge/standalone/apps/websocket/architecture.md +90 -0
- package/knowledge/standalone/apps/websocket/features/sqs-fanout.md +86 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
| [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
|
|
8
8
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
9
9
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
10
|
+
| [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
|
|
10
11
|
| [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
|
|
11
12
|
| [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
|
|
12
13
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Record-Changed Event Publishing (_Event::publish to SQS)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-29
|
|
10
|
+
owners: [rgirish]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Event.php
|
|
13
|
+
related:
|
|
14
|
+
- ../../../../standalone/apps/websocket/features/sqs-fanout.md
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What it is
|
|
18
|
+
|
|
19
|
+
`_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event
|
|
20
|
+
pipeline. After a successful V2 write (POST/PUT/PATCH), it publishes a single JSON message
|
|
21
|
+
to the `toga-events` SQS queue describing what changed. The `websocket` standalone server
|
|
22
|
+
consumes that queue and fans out `record:changed` to browsers. The whole call is wrapped in
|
|
23
|
+
a `try/catch (\Throwable)` so an SQS failure logs but never blocks or errors the API
|
|
24
|
+
response.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
`publish(object &$api, string $httpMethod, array $routeVars)`:
|
|
29
|
+
|
|
30
|
+
1. Reads `aws_event_queue_url` / `aws_event_queue_region` from `_Config::cloud()`; returns
|
|
31
|
+
early if no queue URL is configured.
|
|
32
|
+
2. Determines the affected record's `recordRoute`, `recordId`, `uuid`.
|
|
33
|
+
3. Derives `parentRoute` / `parentUuid` from `$routeVars` when the URL is a nested resource
|
|
34
|
+
(`count($routeVars) >= 3` → `parentRoute = $routeVars[0]`, `parentUuid = $routeVars[1]`).
|
|
35
|
+
4. JSON-encodes `{ clientId, clientUuid, recordRoute, recordId, uuid, httpMethod,
|
|
36
|
+
parentRoute, parentUuid, dtEvent }` and enqueues via `_Cloud::addSqsMessage()`.
|
|
37
|
+
|
|
38
|
+
SQS credentials come from `_Config::cloud('aws_event_queue_access_key_id' /
|
|
39
|
+
'..._secret_access_key')` — values live in config/cloud, never in code or this doc.
|
|
40
|
+
|
|
41
|
+
## Gotchas
|
|
42
|
+
|
|
43
|
+
- **`recordRoute` must come from `$routeVars[0]`, not `$api->record->route`.** Post-`POST`
|
|
44
|
+
interceptors can swap `$api->record` to a *different* model before `publish()` runs (e.g.
|
|
45
|
+
`service-requests`' `postPost` switches `$api->record` to the `service-request-bundles`
|
|
46
|
+
model), so `$api->record->route` would point at the wrong route. Read the original URL
|
|
47
|
+
segment `$routeVars[0]`, falling back to `$api->record->route` only when `routeVars` is
|
|
48
|
+
empty.
|
|
49
|
+
- **`$api->response->data` is a PHP array, not a stdClass object.** `processRoutePairs()`
|
|
50
|
+
in V2.php returns e.g. `['serviceRequests' => $outData]`. Using object access
|
|
51
|
+
(`$data->{$topKey}`) on it silently returns null, so `uuid`/`recordId` were always null in
|
|
52
|
+
published events. Cast first: `$data = (array) $api->response->data;` then
|
|
53
|
+
`$topRecord = (object) $data[array_key_first($data)];` before reading `->id` / `->uuid`.
|
|
54
|
+
|
|
55
|
+
## Change history
|
|
56
|
+
- 2026-06-29 — Fixed `recordRoute` to read `$routeVars[0]` (authoritative URL route) because
|
|
57
|
+
post-interceptors can mutate `$api->record` to a different model before publish. (rgirish)
|
|
58
|
+
- 2026-06-29 — Fixed null `uuid`/`recordId` in SQS events: response data is a PHP array, so
|
|
59
|
+
cast `(array)` before `array_key_first()` and `(object)` the top record before property
|
|
60
|
+
access (was using object access on an array → silent null). (rgirish)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
| [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
|
|
13
13
|
| [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding from an S3 CSV plus an Etilize re-pull. | worker2/Worker/Etilize/Items.php |
|
|
14
14
|
| [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
|
|
15
|
-
| [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
|
|
15
|
+
| [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
|
|
16
16
|
| [NetSuite → TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
17
17
|
| [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
18
18
|
| [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
|
|
@@ -6,17 +6,21 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-29
|
|
10
10
|
owners: [mhammontree]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Monitor.php
|
|
13
13
|
- worker2/Worker/Monitors/
|
|
14
|
+
- worker2/Worker/Monitors/RateEntitlement.php
|
|
14
15
|
- worker2/Worker/Notification/Email.php
|
|
16
|
+
- worker2/Worker/Rate.php
|
|
15
17
|
- dbchanges2/Core/2026-05-21 - Monitors.sql
|
|
18
|
+
- dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql
|
|
16
19
|
related:
|
|
17
20
|
- ../architecture.md
|
|
18
21
|
- ./creating-worker-actions.md
|
|
19
22
|
- ../../dbchanges2/architecture.md
|
|
23
|
+
- ../../../clients/rate/features/aig-contract-creation.md
|
|
20
24
|
---
|
|
21
25
|
|
|
22
26
|
## Summary
|
|
@@ -26,10 +30,11 @@ Prudential asset imports, AIG closed claims, …). One orchestrator applies cons
|
|
|
26
30
|
anti-flap logic and notification rules to any registered "is X healthy?" check. Replaces
|
|
27
31
|
scattered/ad-hoc monitoring where failures surfaced only when a client complained.
|
|
28
32
|
|
|
29
|
-
**Status (2026-06-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
**Status (2026-06-29):** v1.0 framework + orchestrator landed; **first child monitor built**
|
|
34
|
+
— `_Worker_Monitors_RateEntitlement` (TRUE-79129, see [worked example](#worked-example--rateentitlement-the-first-child-monitor)).
|
|
35
|
+
`Core.Monitors` migration applied to local Core_2 only — **still not on staging/production
|
|
36
|
+
Core** (coordinate before merge; the new Rate change-set hard-fails where the table is
|
|
37
|
+
absent). Dashboard/acknowledgment layer under discussion (see Pending scope).
|
|
33
38
|
|
|
34
39
|
Design principles: async isolation (one cron per monitor — a slow monitor can't block
|
|
35
40
|
others) · anti-flap on the recovery side only (N consecutive OKs before declaring
|
|
@@ -131,6 +136,19 @@ Index `Monitors_isActive_IDX (isActive)`. `Core.CronJobs` reused as-is — one r
|
|
|
131
136
|
(recipients see it verbatim); register any non-Core DB connection inline at the top of
|
|
132
137
|
`Run()` — the framework does **not** call `initialize()` on child monitors.
|
|
133
138
|
|
|
139
|
+
**Confirmed against the orchestrator (`Worker/Monitor.php`):**
|
|
140
|
+
- The orchestrator calls `$phpClass::Run()` with **no args** and does **not** call
|
|
141
|
+
`initialize()` on children. A child needing a non-Core connection (e.g. a `Logs_<Client>`
|
|
142
|
+
DB) must self-register it inside `Run()`, guarding against a double-register:
|
|
143
|
+
`if (!isset(_Database::$_registers[$alias])) { _Database::register(db, host, user, pass,
|
|
144
|
+
null, null, $alias); }`, with credentials from `_Config::databaseLogs(...)`. The
|
|
145
|
+
registration pattern mirrors `worker2/Worker/Rate.php::initialize()`.
|
|
146
|
+
- The orchestrator wraps the call in its own try/catch and turns any `Throwable` into an
|
|
147
|
+
alert — children must **not** catch (re-confirms the "fail loudly" rule above).
|
|
148
|
+
- A read-only `Run()` (e.g. a log scan) needs **no** `transactionCommit()`.
|
|
149
|
+
- **PHP heredoc gotcha:** `self::CONST` does **not** interpolate inside a heredoc. Bind
|
|
150
|
+
class constants to local variables before building a heredoc SQL string.
|
|
151
|
+
|
|
134
152
|
### Adding a new monitor (runbook)
|
|
135
153
|
|
|
136
154
|
1. Write the child class (one check, returns `{isOk, message}`).
|
|
@@ -150,17 +168,59 @@ No Lambda, SQS, or orchestrator changes needed per monitor.
|
|
|
150
168
|
- Pause a monitor: `isActive = 0` (cron row can stay active). Pause email only: `peopleToNotify = JSON_ARRAY()`.
|
|
151
169
|
- Manual state reset: clear `state`/`consecutiveOkCount`/`lastNotificationDt`/`lastNotificationType` — sparingly; the state machine self-corrects.
|
|
152
170
|
|
|
171
|
+
### Worked example — RateEntitlement (the first child monitor)
|
|
172
|
+
|
|
173
|
+
`_Worker_Monitors_RateEntitlement` (`worker2/Worker/Monitors/RateEntitlement.php`, the
|
|
174
|
+
**first** file in `Worker/Monitors/`) watches Rate's AIG warranty-contract creation
|
|
175
|
+
(TRUE-79129). It is the reference implementation for the **log-scan** monitor pattern:
|
|
176
|
+
|
|
177
|
+
1. `Run()` self-registers the `Logs_Rate` connection (the guard + `_Config::databaseLogs`
|
|
178
|
+
pattern above), because the orchestrator never calls `initialize()`.
|
|
179
|
+
2. Scans `Logs_Rate.Api` for **failed outbound** AIG contract POSTs in the last 30 minutes
|
|
180
|
+
— `direction = 'OUT'`, `method = 'POST'`, `route LIKE '%/contract'`, and
|
|
181
|
+
`responseCode` non-2xx **or NULL**. Returns `(object){isOk, message}`; read-only, so no
|
|
182
|
+
commit.
|
|
183
|
+
3. The detection signal is the `_underscore` **outbound API log**, not the entitlement
|
|
184
|
+
itself — see [why a monitor is needed](#why-an-external-monitor) below.
|
|
185
|
+
|
|
186
|
+
The change-set `dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql` does
|
|
187
|
+
the registration: one `Core.Monitors` row (`phpClass = _Worker_Monitors_RateEntitlement`,
|
|
188
|
+
`peopleToNotify = ['devteam@goagilant.com']`, `requiredConsecutiveOks = 2`,
|
|
189
|
+
`reminderFrequencyMinutes = 60`) + one `Core.CronJobs` row (`action = 'Monitor/Run'`,
|
|
190
|
+
`parameters = JSON_OBJECT('monitorId', LAST_INSERT_ID())`, schedule `*/15`,
|
|
191
|
+
`maxExecutionTime = 60`). LAST_INSERT_ID() chains the cron row to the just-inserted monitor.
|
|
192
|
+
|
|
193
|
+
### Detection via the _underscore outbound API log
|
|
194
|
+
|
|
195
|
+
`_underscore/ApiRequest.php` logs **every** outbound call to `_Model_Client_Logs_Api`
|
|
196
|
+
(table `Logs_<Client>.Api`) and **commits it immediately**, independent of whether the
|
|
197
|
+
caller later swallows the exception — which is exactly what makes log-scan monitoring
|
|
198
|
+
possible for silent-failure interceptors. Columns to scan: `dtStamp`
|
|
199
|
+
(`FIELD_DATETIME_CREATED`), `direction` (`OUT`), `method`, `hostname` (scheme://host),
|
|
200
|
+
`route` (path after host), `responseCode`. `setUrl()` splits a full URL into
|
|
201
|
+
`hostname` + `route`, so a call to `.../contract` is logged with `route` ending `/contract`
|
|
202
|
+
(and cancellation as `/contract/cancel`). **Pattern for new monitors:** scan the client's
|
|
203
|
+
`Logs_<Client>.Api` rather than trying to detect failure on the business object.
|
|
204
|
+
|
|
205
|
+
### Why an external monitor
|
|
206
|
+
|
|
207
|
+
Some interceptors **silently swallow** their own failures (no persistent flag on the
|
|
208
|
+
business record), so the only durable failure signal is the committed outbound API log.
|
|
209
|
+
The Rate case is documented in
|
|
210
|
+
[Rate AIG contract creation](../../../clients/rate/features/aig-contract-creation.md).
|
|
211
|
+
|
|
153
212
|
## Client variations
|
|
154
213
|
|
|
155
214
|
None — the framework is shared Core infrastructure. Individual monitors target specific
|
|
156
|
-
clients' data flows (Compass, Prudential, AIG, …) but live as separate child classes.
|
|
215
|
+
clients' data flows (Compass, Prudential, AIG, Rate, …) but live as separate child classes.
|
|
157
216
|
|
|
158
217
|
## Gotchas / known issues
|
|
159
218
|
|
|
160
|
-
- **
|
|
161
|
-
(as of 2026-06-10
|
|
162
|
-
-
|
|
163
|
-
|
|
219
|
+
- **Migration not applied to staging/production Core** — `Core.Monitors`
|
|
220
|
+
(`2026-05-21 - Monitors.sql`) is on local Core_2 only (as of 2026-06-10, re-confirmed
|
|
221
|
+
2026-06-29). The new `2026-06-29a` Rate change-set will **hard-fail (table not found)** on
|
|
222
|
+
any environment where `2026-05-21 - Monitors.sql` has not yet been promoted — confirm the
|
|
223
|
+
Monitors table exists before deploying.
|
|
164
224
|
- `worker2/MONITORING_PLAN.md` is referenced by the design doc but **missing on disk** —
|
|
165
225
|
stale reference to resolve.
|
|
166
226
|
- Child return value must be an **object** with both `isOk` and `message`; a bare array
|
|
@@ -177,6 +237,7 @@ clients' data flows (Compass, Prudential, AIG, …) but live as separate child c
|
|
|
177
237
|
HTML email + dashboard deep-links · anti-flap on the alarm side.
|
|
178
238
|
|
|
179
239
|
## Change history
|
|
240
|
+
- 2026-06-29 — Built the first child monitor, `_Worker_Monitors_RateEntitlement` (TRUE-79129) + change-set `2026-06-29a`; added the log-scan worked example, the `_underscore` outbound-API-log detection pattern, and confirmed child details (orchestrator never calls `initialize()` so children self-register non-Core connections; no commit on read-only `Run()`; heredoc cannot interpolate `self::CONST`). Re-flagged that `Core.Monitors` is still local-only — the new change-set hard-fails where the table is absent. (mhammontree)
|
|
180
241
|
- 2026-06-10 — Documented the v1.0 monitoring framework (orchestrator, `Core.Monitors` table, recovery-side anti-flap state machine, child contract). First child monitor + staging/prod migration still pending. (mhammontree)
|
|
181
242
|
|
|
182
243
|
## Related docs
|
package/knowledge/INDEX.md
CHANGED
|
@@ -16,7 +16,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
16
16
|
|
|
17
17
|
## 2.0 framework
|
|
18
18
|
|
|
19
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
19
|
+
- **_underscore** (_Underscore) _(framework core)_ — 18 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
20
20
|
- **worker2** (Worker) — 22 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
21
21
|
- **api2** (API) — 7 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
22
22
|
- **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
@@ -34,6 +34,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
34
34
|
## standalone framework
|
|
35
35
|
|
|
36
36
|
- **togatech** (TOGA Technology Website) — 4 doc(s) → [standalone/apps/togatech/INDEX.md](standalone/apps/togatech/INDEX.md)
|
|
37
|
+
- **websocket** (WebSocket Server) — 2 doc(s) → [standalone/apps/websocket/INDEX.md](standalone/apps/websocket/INDEX.md)
|
|
37
38
|
- **forward** (Forwarder) — 4 doc(s) → [standalone/apps/forward/INDEX.md](standalone/apps/forward/INDEX.md)
|
|
38
39
|
|
|
39
40
|
## Clients
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Framework | Summary | Files |
|
|
4
4
|
|-----|-----------|---------|-------|
|
|
5
|
+
| [Rate AIG Warranty Contract Creation (silent-failure interceptor)](features/aig-contract-creation.md) | 2.0 | When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost` (`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a non | _underscore/Model/Rate/Entitlement.php, _underscore/ApiRequest.php, worker2/Worker/Monitors/RateEntitlement.php |
|
|
5
6
|
| [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
|
|
6
7
|
| [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
|
|
7
8
|
| [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Rate AIG Warranty Contract Creation (silent-failure interceptor)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: rate
|
|
7
|
+
type: client-feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-29
|
|
10
|
+
owners: [mhammontree]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Rate/Entitlement.php
|
|
13
|
+
- _underscore/ApiRequest.php
|
|
14
|
+
- worker2/Worker/Monitors/RateEntitlement.php
|
|
15
|
+
related:
|
|
16
|
+
- clients/rate/profile.md
|
|
17
|
+
- ../../../2.0/apps/worker2/features/monitoring-framework.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost`
|
|
23
|
+
(`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a
|
|
24
|
+
non-critical side effect: it authenticates against AIG, POSTs to `.../contract`, and on
|
|
25
|
+
success stamps `payload->c_aigContractNumber` and `payload->c_aigContractId` back onto the
|
|
26
|
+
entitlement. **It only attempts this when the entitlement payload has a primary contact
|
|
27
|
+
address.**
|
|
28
|
+
|
|
29
|
+
The catch: **every failure path is silent.** There is no persistent failure flag on the
|
|
30
|
+
entitlement, so a contract that never got created looks identical to one that did until a
|
|
31
|
+
customer or AIG reports it missing. That is why this flow is watched externally by the
|
|
32
|
+
`_Worker_Monitors_RateEntitlement` monitor rather than by any state on the entitlement.
|
|
33
|
+
|
|
34
|
+
This is a base-`_underscore` interceptor specific to Rate's product (it lives under
|
|
35
|
+
`Model/Rate/`); documented here as a Rate client-feature because the behavior and its
|
|
36
|
+
monitoring are Rate-scoped.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. `postPost` runs after an entitlement is saved. It proceeds **only if** the payload has a
|
|
41
|
+
primary contact address; otherwise it does nothing.
|
|
42
|
+
2. Authenticate against AIG (POST `/authentication/login`) → obtain an access token.
|
|
43
|
+
3. POST the contract payload to `.../contract`.
|
|
44
|
+
4. On success: read `aigContractNumber` from the response and set
|
|
45
|
+
`payload->c_aigContractNumber` + `payload->c_aigContractId`.
|
|
46
|
+
|
|
47
|
+
## Silent-failure paths (the gotcha)
|
|
48
|
+
|
|
49
|
+
All of the following fail **silently** — no exception surfaces to the caller and nothing is
|
|
50
|
+
persisted on the entitlement:
|
|
51
|
+
|
|
52
|
+
- A `try/catch` that only `error_log()`s with the note *"AIG contract creation is
|
|
53
|
+
non-critical"* and swallows the exception.
|
|
54
|
+
- **Auth response non-2xx** (no throw).
|
|
55
|
+
- **No access token** returned (no throw).
|
|
56
|
+
- **Contract POST non-2xx** (no throw).
|
|
57
|
+
- **Success response but missing `aigContractNumber`** (no throw).
|
|
58
|
+
|
|
59
|
+
Net effect: there is no durable signal on the entitlement that contract creation failed.
|
|
60
|
+
|
|
61
|
+
## Detection / monitoring
|
|
62
|
+
|
|
63
|
+
Because the interceptor swallows failures, detection rides the **committed outbound API
|
|
64
|
+
log** instead. `_underscore/ApiRequest.php` logs every outbound call to
|
|
65
|
+
`_Model_Client_Logs_Api` (table `Logs_Rate.Api`) and commits it immediately, regardless of
|
|
66
|
+
whether the caller later swallows the exception. The AIG contract call lands with
|
|
67
|
+
`direction = OUT`, `method = POST`, and `route LIKE '%/contract'` (cancellation is
|
|
68
|
+
`/contract/cancel`).
|
|
69
|
+
|
|
70
|
+
`_Worker_Monitors_RateEntitlement` (worker2) scans `Logs_Rate.Api` for failed AIG contract
|
|
71
|
+
POSTs (`route LIKE '%/contract'`, `responseCode` non-2xx **or NULL**) in the last 30
|
|
72
|
+
minutes and alerts the dev team. See the
|
|
73
|
+
[Monitoring Framework worked example](../../../2.0/apps/worker2/features/monitoring-framework.md#worked-example--rateentitlement-the-first-child-monitor).
|
|
74
|
+
|
|
75
|
+
### Known detection limitation (decided)
|
|
76
|
+
|
|
77
|
+
Detection matches **only** `POST .../contract` (`route LIKE '%/contract'`), which
|
|
78
|
+
deliberately **excludes** `/contract/cancel` and the shared `/authentication/login`.
|
|
79
|
+
Consequence: a creation failure caused by an **auth failure** is **not** caught, because
|
|
80
|
+
`/authentication/login` is shared with the cancellation flow and so cannot be attributed to
|
|
81
|
+
creation vs. cancellation. This is an accepted, documented limitation — not an oversight.
|
|
82
|
+
|
|
83
|
+
## Gotchas / known issues
|
|
84
|
+
|
|
85
|
+
- **No persistent failure flag** — the only durable evidence of a failed contract creation
|
|
86
|
+
is the outbound API log row, not the entitlement.
|
|
87
|
+
- **Address-gated** — `postPost` does nothing when the payload lacks a primary contact
|
|
88
|
+
address, so an entitlement with no address never even attempts contract creation (and
|
|
89
|
+
never logs a `/contract` call).
|
|
90
|
+
- **Auth-induced failures are blind to the monitor** — see the detection limitation above.
|
|
91
|
+
|
|
92
|
+
## Change history
|
|
93
|
+
|
|
94
|
+
- 2026-06-29 — Documented the silent-failure AIG warranty-contract interceptor and the
|
|
95
|
+
external log-scan monitor (`_Worker_Monitors_RateEntitlement`, TRUE-79129), including the
|
|
96
|
+
accepted detection limitation that auth-induced failures are not attributable (shared
|
|
97
|
+
`/authentication/login`). (mhammontree)
|
|
@@ -6,17 +6,21 @@ apps:
|
|
|
6
6
|
- saml
|
|
7
7
|
- toga2-view
|
|
8
8
|
- worker
|
|
9
|
+
- worker2
|
|
10
|
+
- dbchanges2
|
|
9
11
|
project: SAML SSO Gateway
|
|
10
12
|
client: rate
|
|
11
13
|
type: profile
|
|
12
14
|
status: active
|
|
13
|
-
updated: 2026-06-
|
|
14
|
-
owners: ["rgirish", "bala"]
|
|
15
|
+
updated: 2026-06-29
|
|
16
|
+
owners: ["rgirish", "bala", "mhammontree"]
|
|
15
17
|
files: []
|
|
16
18
|
related:
|
|
17
19
|
- clients/rate/features/saml-sso.md
|
|
18
20
|
- clients/rate/features/monthly-reconciliation-report.md
|
|
19
21
|
- clients/rate/features/netsuite-cashsale-export.md
|
|
22
|
+
- clients/rate/features/service-card-entitlements.md
|
|
23
|
+
- clients/rate/features/aig-contract-creation.md
|
|
20
24
|
---
|
|
21
25
|
|
|
22
26
|
## Summary
|
|
@@ -60,7 +60,7 @@ Credit Card Receipts/
|
|
|
60
60
|
- **`.docx` files bypass Talos** and route to `extractDocxData()` (Talos returns HTTP 500 on a `.docx` MIME type — it only accepts PDF/image). That method opens the docx as a ZIP, extracts `word/document.xml`, strips tags, and regex-parses `MM/DD/YY: description` lines into `line_items[]`
|
|
61
61
|
- All other types POST to Talos AI `/api/ai/generate` → structured `{vendor_name, invoice_date, total, payment_memo, category, ...}`
|
|
62
62
|
- **Year guard on AI dates** — if Talos returns an `invoice_date` whose year is more than 1 year from the current year (AI hallucination on two-digit year inputs, e.g. `5/13/76` → 1976, `5/28/28` → 2028), the year is clamped to the current year while month/day are preserved
|
|
63
|
-
- Cross-verify amount ± $0.01 against parsed statement; if matched,
|
|
63
|
+
- Cross-verify amount ± $0.01 against parsed statement; if matched, the statement description **may** override `payment_memo`. The statement memo is the **authoritative source for activity-purpose headings** (Grantee meeting, Peer meeting, Grantee event, etc.) — but only when it actually *is* such a heading. The Amex statement's `Description` column for ride-share/taxi charges holds raw bank strings (e.g. `"AplPay LYFT 855-280-0278 CA"`, `"UBER"`), not activity headings. **Priority order (enforced):** (1) statement memo wins only if it is a proper activity heading; (2) otherwise the AI memo is kept — including when the statement only has a raw bank string, because the AI's descriptive memo (e.g. `"Transportation: Lyft ride from East Side at 105th to 925 9th Ave"`) is more useful than a raw Apple Pay string. The override condition is `empty($extracted->payment_memo) || self::isActivityHeadingMemo($matched)`. `isActivityHeadingMemo()` returns true only when the statement memo (left-trimmed, lowercased) starts with a recognised activity-purpose prefix followed by a colon (`grantee meeting:`, `grantee event:`, `peer meeting:`, `colleague meeting:`, `staff theater:`, `professional development:`, `dining:`, `travel:`). This **replaces** the earlier `isBareTranportationMemo()` approach, which fired whenever the AI memo started with `"Transportation:"` and so let the raw bank string overwrite a useful AI memo on ride-share rows (degraded rows 1, 17, 18, 21, 26, 31, 32, 34 on Emily Tow's statement).
|
|
64
64
|
- **Zero-total fallback** — if the extracted/docx total is 0, `lookupStatementTotalByVendor()` derives the amount from the billing statement (see *Statement structure* below)
|
|
65
65
|
- Build base filename (no suffix yet) and store in `$pendingRenames`
|
|
66
66
|
- On extract failure → move to `Archive/exception/` immediately
|
|
@@ -236,6 +236,7 @@ Fatal errors send only to `NOTIFY_EMAIL_DEV` (no CC/BCC).
|
|
|
236
236
|
|
|
237
237
|
## Change history
|
|
238
238
|
|
|
239
|
+
- 2026-06-29 — **Reversed the over-aggressive memo override.** Replaced `isBareTranportationMemo()` (added earlier same day) with `isActivityHeadingMemo()`: the statement memo now overrides the AI memo **only** when it is a proper activity-purpose heading (e.g. `Grantee meeting:`, `Peer meeting:`), not whenever the AI memo started with `"Transportation:"`. The previous approach replaced semi-useful AI memos with raw bank strings (`"AplPay LYFT …"`, `"UBER"`) on ride-share/taxi rows where the statement has no activity heading — degraded 8 rows on Emily Tow's statement (1, 17, 18, 21, 26, 31, 32, 34). New priority: statement heading > AI memo, with AI memo kept for any non-heading statement description. (rgirish)
|
|
239
240
|
- 2026-06-29 — `payment_memo` override now also fires on a **bare** `"Transportation:"` AI memo, not just an empty one: new `isBareTranportationMemo()` lets the cardholder's statement Excel (the authoritative source for activity-purpose headings like "Peer meeting:", "Grantee event:") override the AI's generic transportation heading. Fixes wrong headings on Emily Tow May 2026 transit rows. Added the committed `MoveBack` action (+ `collectFilesRecursiveRaw()`) to restore a person's archived receipts into a billing-cycle folder for reprocessing, replacing the throwaway `/tmp/tow_restore_archives.php` script. (rgirish)
|
|
240
241
|
- 2026-06-25 — Statement parsing hardened for Emily Tow's May 2026 Amex xlsx: `loadStatementExcel()` now iterates `getAllSheets()` (4 weekly-period sheets) instead of `getActiveSheet()` and captures the `Receipt` column; `lookupStatementTotalByVendor()` now **sums** all receipt-label-matched rows (14 × $3.00 "Subway Rides" = $42.00) before falling back to description-keyword match — fixes subway totals reading $0/$9.00. Added `extractDocxData()` so `.docx` receipts bypass Talos (Talos 500s on docx), parsing `MM/DD/YY: description` line items into per-entry Excel rows. Added a year guard clamping AI-hallucinated `invoice_date` years (e.g. 1976, 2028) to the current year. Fixed archive-path bug: SharePoint move now uses the actual folder name (`"Emily Tow CC receipts"`) not the normalized name, fixing 404 on archive moves. 5 more vendors found missing from the QB vendor list (client action). (rgirish)
|
|
241
242
|
- 2026-06-23 — `TowFoundationCategories.php` added: AI category values now mapped to exact QB account strings via `mapCategory()`; `"Transportation"` → `"6610 Travel expense"` etc. (19 mappings). QB vendor substring match minimum lowered from 4 to 3 chars with whole-word boundary guard for short needles — fixes `"CVS"` → `"CVS Pharmacy"`, `"MTA"` → `"MTA Metro card"`, prevents `"UPS"` → `"USPS"` false positive. (rgirish)
|
package/knowledge/registry.json
CHANGED
|
@@ -96,6 +96,16 @@
|
|
|
96
96
|
"role": "app",
|
|
97
97
|
"dependsOn": []
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
"repo": "websocket",
|
|
101
|
+
"project": "WebSocket Server",
|
|
102
|
+
"framework": "standalone",
|
|
103
|
+
"role": "app",
|
|
104
|
+
"dependsOn": [
|
|
105
|
+
"api2",
|
|
106
|
+
"_underscore"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
99
109
|
{
|
|
100
110
|
"repo": "webhook",
|
|
101
111
|
"project": "Webhook",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# websocket (WebSocket Server) — standalone knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [WebSocket Server Architecture](architecture.md) | `websocket` (npm package `toga-socket-server`) is the real-time push tier for TOGA 2.0 frontends. | websocket/index.js, websocket/auth.js, websocket/rooms.js, websocket/fanout.js, websocket/metadata.js, websocket/ecosystem.config.js, websocket/aws-setup.sh, websocket/package.json |
|
|
6
|
+
| [SQS Fan-out to Socket.io Rooms](features/sqs-fanout.md) | The fan-out logic that turns one SQS `toga-events` message (published by api2 after a write) into `record:changed` emits to every Socket.io room that should lea | websocket/fanout.js, websocket/metadata.js, websocket/rooms.js |
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: WebSocket Server Architecture
|
|
3
|
+
framework: "standalone"
|
|
4
|
+
repo: websocket
|
|
5
|
+
project: WebSocket Server
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-29
|
|
10
|
+
owners: [rgirish]
|
|
11
|
+
files:
|
|
12
|
+
- websocket/index.js
|
|
13
|
+
- websocket/auth.js
|
|
14
|
+
- websocket/rooms.js
|
|
15
|
+
- websocket/fanout.js
|
|
16
|
+
- websocket/metadata.js
|
|
17
|
+
- websocket/ecosystem.config.js
|
|
18
|
+
- websocket/aws-setup.sh
|
|
19
|
+
- websocket/package.json
|
|
20
|
+
related:
|
|
21
|
+
- ../../../2.0/apps/_underscore/features/event-publish-sqs.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Summary
|
|
25
|
+
|
|
26
|
+
`websocket` (npm package `toga-socket-server`) is the real-time push tier for TOGA 2.0
|
|
27
|
+
frontends. It is a **standalone Node.js / Socket.io server** — it uses neither PHP
|
|
28
|
+
framework (`_underscore` 2.0 nor `App_` 1.0), which is why it lives under the
|
|
29
|
+
`standalone/` knowledge partition. It consumes the `toga-events` SQS queue published by
|
|
30
|
+
`api2` (via `_Event::publish()` in `_underscore`) and fans out `record:changed` events to
|
|
31
|
+
connected browsers over Socket.io rooms. It ships no client-facing CRUD of its own.
|
|
32
|
+
|
|
33
|
+
**Critical rules:**
|
|
34
|
+
- It is a **consumer** of an event contract owned by the PHP side — the event payload
|
|
35
|
+
shape is defined by `_underscore`'s `_Event::publish()`. Changes to the payload must be
|
|
36
|
+
coordinated across both repos (see the related `event-publish-sqs` feature doc).
|
|
37
|
+
- Room keys use **`clientUuid`**, not `clientId` — the per-tenant isolation boundary.
|
|
38
|
+
- All secrets (`TOGA_JWT_SECRET`/`API_SECRET_ACCESS_TOKEN`, `SQS_*`, `DB_*`) come from env
|
|
39
|
+
(`.env` locally, Elastic Beanstalk env vars in prod) or the Core DB. Never commit values.
|
|
40
|
+
- JWT secrets are **sourced at runtime from `Core.Parameters`** (`API_SECRET_ACCESS_TOKEN`
|
|
41
|
+
+ `_PREVIOUS`) so the server stays in sync as api2 rotates them every 3–10 days.
|
|
42
|
+
|
|
43
|
+
## Stack
|
|
44
|
+
|
|
45
|
+
- **Node.js ≥ 18**, **Socket.io 4.7**, **@aws-sdk/client-sqs 3**, **mysql2 3**,
|
|
46
|
+
**jsonwebtoken 9** (HS256), **dotenv**.
|
|
47
|
+
- Process management via **PM2** (`ecosystem.config.js`, 2 workers, auto-restart).
|
|
48
|
+
- Deployed to **AWS Elastic Beanstalk**; `aws-setup.sh` provisions an instance and is
|
|
49
|
+
reusable across multiple environments.
|
|
50
|
+
|
|
51
|
+
## Pipeline
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
api2 PHP write (POST/PUT/PATCH)
|
|
55
|
+
→ _underscore _Event::publish() → SQS toga-events queue
|
|
56
|
+
→ this server polls SQS (long-poll loop)
|
|
57
|
+
→ fanOut() → Socket.io rooms → connected browsers (record:changed)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Files
|
|
61
|
+
|
|
62
|
+
| File | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `index.js` | HTTP server, Socket.io init, SQS poll loop startup |
|
|
65
|
+
| `auth.js` | JWT validation middleware (HS256, same secret as api2) |
|
|
66
|
+
| `rooms.js` | `subscribe` / `unsubscribe` handlers; `listRoom()` / `instanceRoom()` helpers |
|
|
67
|
+
| `fanout.js` | SQS message → room emit (fan-out to list + instance + parent + child rooms) |
|
|
68
|
+
| `metadata.js` | Core DB cache (route→id, parent/children maps, JWT secrets); refreshed every 5 min |
|
|
69
|
+
| `ecosystem.config.js` | PM2 config |
|
|
70
|
+
| `aws-setup.sh` | Elastic Beanstalk provisioning |
|
|
71
|
+
|
|
72
|
+
## Room naming
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
List room: toga:{clientUuid}:record:{recordRoute}
|
|
76
|
+
Instance room: toga:{clientUuid}:record:{recordRoute}:{uuid}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Metadata cache (`metadata.js`)
|
|
80
|
+
|
|
81
|
+
A single Core DB connection-per-refresh (every 5 minutes, plus at startup) populates three
|
|
82
|
+
in-memory maps plus the JWT secret list:
|
|
83
|
+
|
|
84
|
+
- `routeMap` — `route` string → `recordId` (from `Core.Records`).
|
|
85
|
+
- `parentMap` — child `recordId` → parent `recordId` (from `Core.InherentRecordChildren`,
|
|
86
|
+
column `inherentChildOfRecordId`).
|
|
87
|
+
- `childrenMap` — parent `recordId` → `Set` of child `recordId`s (inverse of `parentMap`).
|
|
88
|
+
- `jwtSecrets` — `[current, previous]` from `Core.Parameters`, checked in order on verify.
|
|
89
|
+
|
|
90
|
+
Accessors: `getRouteMap()`, `getParentMap()`, `getChildrenMap()`, `getJwtSecrets()`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SQS Fan-out to Socket.io Rooms
|
|
3
|
+
framework: "standalone"
|
|
4
|
+
repo: websocket
|
|
5
|
+
project: WebSocket Server
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-29
|
|
10
|
+
owners: [rgirish]
|
|
11
|
+
files:
|
|
12
|
+
- websocket/fanout.js
|
|
13
|
+
- websocket/metadata.js
|
|
14
|
+
- websocket/rooms.js
|
|
15
|
+
related:
|
|
16
|
+
- ../../../../2.0/apps/_underscore/features/event-publish-sqs.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What it is
|
|
20
|
+
|
|
21
|
+
The fan-out logic that turns one SQS `toga-events` message (published by api2 after a
|
|
22
|
+
write) into `record:changed` emits to every Socket.io room that should learn about the
|
|
23
|
+
change. Implemented in `fanOut(event)` in `websocket/fanout.js`, driven by a continuous
|
|
24
|
+
SQS long-poll loop.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
`pollLoop()` long-polls SQS (`MaxNumberOfMessages: 10`, `WaitTimeSeconds: 20`), parses each
|
|
29
|
+
message body to an `event`, calls `fanOut(event)`, then deletes the message. Malformed
|
|
30
|
+
bodies are logged and deleted (not retried); fan-out errors are logged without deleting
|
|
31
|
+
fan-out from blocking the loop. On a receive error it backs off 5s before retrying.
|
|
32
|
+
|
|
33
|
+
`fanOut(event)` reads `{ clientUuid, recordRoute, uuid, httpMethod, dtEvent, parentRoute,
|
|
34
|
+
parentUuid }` and emits to up to four kinds of rooms (all keyed by `clientUuid`):
|
|
35
|
+
|
|
36
|
+
1. **Own list room** — `listRoom(clientUuid, recordRoute)` — always.
|
|
37
|
+
2. **Own instance room** — `instanceRoom(clientUuid, recordRoute, uuid)` — only when `uuid`
|
|
38
|
+
is present.
|
|
39
|
+
3. **Inherent children's list rooms** — for each child of the fired record, emit to that
|
|
40
|
+
child's list room with `{ ...payload, parentRoute: recordRoute }`. Resolved from the
|
|
41
|
+
metadata `childrenMap` (parent recordId → Set of child recordIds), looked up via
|
|
42
|
+
`routeMap` to translate the fired `recordRoute` → `recordId` → child recordIds → child
|
|
43
|
+
routes. This lets a list view of children refresh when the parent changes (e.g. a
|
|
44
|
+
`service-requests` write notifies `service-request-bundles`, `service-request-units`,
|
|
45
|
+
and `service-request-notes` list rooms).
|
|
46
|
+
4. **Parent rooms** — when the event itself is a nested write, notify the parent's list
|
|
47
|
+
room (always) and instance room (only if `parentUuid` present) with
|
|
48
|
+
`{ ...payload, childRoute: recordRoute }`.
|
|
49
|
+
|
|
50
|
+
### Parent-route resolution from metadata
|
|
51
|
+
|
|
52
|
+
When api2 posts a flat URL with no `parentRoute` in the event, `fanOut()` resolves the
|
|
53
|
+
parent itself: it reuses the `recordId` already looked up for step 3, then reads
|
|
54
|
+
`getParentMap().get(recordId)` (child recordId → parent recordId) and reverse-looks-up the
|
|
55
|
+
parent's route from `routeMap`. So parent notifications work even when the publisher did
|
|
56
|
+
not supply `parentRoute`.
|
|
57
|
+
|
|
58
|
+
## Frontend subscription
|
|
59
|
+
|
|
60
|
+
The frontend joins rooms by emitting `subscribe` once per route — there is **no server-side
|
|
61
|
+
array support and none is needed**. Multiple sequential emits each join a distinct
|
|
62
|
+
Socket.io room and are effectively simultaneous from the client's perspective:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const routes = ['service-requests', 'service-request-bundles', 'service-request-units'];
|
|
66
|
+
routes.forEach(route => socket.emit('subscribe', { record: route }));
|
|
67
|
+
socket.on('record:changed', (payload) => { /* refresh the matching list/detail view */ });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Gotchas
|
|
71
|
+
|
|
72
|
+
- Room keys are built from **`clientUuid`**, never `clientId` — using `clientId` cross-wires
|
|
73
|
+
tenants or misses rooms entirely.
|
|
74
|
+
- The children-room lookup is a nested scan of `routeMap` (recordId → route) per child; it
|
|
75
|
+
is correct but O(routes × children) — fine at current route counts, watch if routes grow.
|
|
76
|
+
- Children notification depends on `Core.InherentRecordChildren` being populated and the
|
|
77
|
+
metadata cache being fresh (refreshes every 5 min) — a brand-new parent/child relation
|
|
78
|
+
will not fan out until the next refresh.
|
|
79
|
+
|
|
80
|
+
## Change history
|
|
81
|
+
- 2026-06-29 — Added inherent-children list-room notification: a parent write now emits
|
|
82
|
+
`record:changed` (with `parentRoute`) to each inherent child's list room, via new
|
|
83
|
+
`childrenMap` / `getChildrenMap()` in metadata.js. Parent-route resolution reuses the
|
|
84
|
+
recordId/routeMap lookup from the children step. (rgirish)
|
|
85
|
+
- 2026-06-26 — Initial fan-out loop: SQS long-poll → list + instance + parent rooms, with
|
|
86
|
+
malformed-message drop and fan-out error isolation. (rgirish)
|
package/package.json
CHANGED