toga-ai 1.0.618 → 1.0.620
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/1.0/apps/library/features/netsuite-suiteql-api-reference.md +33 -3
- package/knowledge/2.0/apps/dbchanges2/workflows/nonprod-metadata-drift-repair.md +31 -2
- package/knowledge/2.0/apps/worker2/INDEX.md +3 -1
- package/knowledge/2.0/apps/worker2/features/elastic-beanstalk-health-monitor.md +66 -21
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +26 -2
- package/knowledge/2.0/apps/worker2/features/netsuite-supporting-record-backfill-worker.md +126 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-supporting-record-webhook-importer.md +85 -9
- package/knowledge/2.0/apps/worker2/workflows/running-worker2-locally.md +123 -0
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["dfranks", "jcardinal"]
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["dfranks", "jcardinal", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/netsuite/rest.php
|
|
13
13
|
- library/ssl/netsuite_ec_key.pem
|
|
@@ -99,6 +99,17 @@ In production (worker/EB) `send()` is fine — the Logs DB is reachable there.
|
|
|
99
99
|
`'YYYY-MM'` for bucketing, `NVL(...)`, and `BUILTIN.DF(field)` for a reference field's display
|
|
100
100
|
value (e.g. `BUILTIN.DF(entity)` → customer name, `BUILTIN.DF(status)` → "Pending Fulfillment") —
|
|
101
101
|
a cheap alternative to per-id GETs for display strings.
|
|
102
|
+
- **⚠ Column ALIASES come back LOWER-CASED.** `SELECT … AS accountTypeLabel` returns the response key
|
|
103
|
+
**`accounttypelabel`**. Reading it back in camelCase (`$row->accountTypeLabel`) yields **`NULL` with no
|
|
104
|
+
error, no warning and no exception** — the query succeeded, the job reports success, and every value is
|
|
105
|
+
quietly empty. Confirmed empirically on this account (`BUILTIN.DF(sd.recordtype) AS recordTypeName` →
|
|
106
|
+
`recordtypename`). **Write all-lowercase aliases, or read the lower-cased key.** This has caused two
|
|
107
|
+
separate defects in the 2.0 customer sync, one of which is still live.
|
|
108
|
+
- **⚠ A List/Record custom field selected RAW returns the list member's INTERNAL ID, not its label.**
|
|
109
|
+
Nothing errors — you simply store `3` where the report expects "Strategic Growth". Wrap it:
|
|
110
|
+
`BUILTIN.DF(custentity_<x>)`. This applies to **every** List/Record custom field, and it is not
|
|
111
|
+
discoverable from the column name, so **probe the field before trusting a raw select** (issue the
|
|
112
|
+
`BUILTIN.DF` form once and compare). See the `customer` table section for a worked example.
|
|
102
113
|
- **`ORDER BY` inside a `GROUP BY` query is rejected** with `Invalid or unsupported search`. Sort in
|
|
103
114
|
PHP after fetching all pages.
|
|
104
115
|
- **Correlated subqueries are supported** (used in `listSales()` for `orderLine` — see the
|
|
@@ -261,6 +272,15 @@ work; the pk is `nkey`). Columns: `addr1`, `addr2`, `addressee`, `attention`, `c
|
|
|
261
272
|
4968, script id `custentity10`); SOAP `customFieldList` CF internalId 4968 maps to this.
|
|
262
273
|
- `entityid` and `companyname` are split in SuiteQL; SOAP concatenated them as
|
|
263
274
|
`"<entityid> <companyname>"`. When they're equal, return the value once (don't duplicate).
|
|
275
|
+
`App_Api_Netsuite_Rest::listCustomers()` is the authoritative implementation of that rule — any other
|
|
276
|
+
writer of a customer *name* column must mirror it exactly, or the two writers will fight
|
|
277
|
+
(`"6096"` vs `"6096 ODP Veyer (B2B)"`). Verified 2026-08-19 on four production-sourced names.
|
|
278
|
+
- **`BUILTIN.DF(custentity_account_type)`** = display label of the **Account Type** entity field
|
|
279
|
+
(NetSuite internal id **10719**). It is a **List/Record** field, so a raw `SELECT
|
|
280
|
+
custentity_account_type` gives you the member's internal id. Live map (verified 2026-08-19):
|
|
281
|
+
**1 = Strategic Emerging · 2 = Not Strategic · 3 = Strategic Growth · 4 = Strategic Maintain ·
|
|
282
|
+
5 = Strategic Draining.** Sparsely populated — **248 of 9,183** customers (2.7%) have it set, so a
|
|
283
|
+
mostly-empty result is correct, not a broken query.
|
|
264
284
|
|
|
265
285
|
## `systemnote` table — field-change forensics
|
|
266
286
|
|
|
@@ -283,7 +303,8 @@ field changed, **including sublist LINE edits that fire no User Event and theref
|
|
|
283
303
|
|---------------|-----------|-------------|
|
|
284
304
|
| 3149 | `CUSTBODY_END_CUSTOMER` | End Customer (on transaction) |
|
|
285
305
|
| 7097 | `CUSTBODY_STOCKING_ORDER` | Stocking Order flag |
|
|
286
|
-
| 4968 | `custentity10` | Consolidated Customer (on customer record) |
|
|
306
|
+
| 4968 | `custentity10` | Consolidated Customer (on customer record) — List/Record; needs `BUILTIN.DF` |
|
|
307
|
+
| 10719 | `custentity_account_type` | Account Type (on customer record) — List/Record; needs `BUILTIN.DF` |
|
|
287
308
|
| 6840 | `custbody_forecast_category` | Forecast Category (on opportunity) |
|
|
288
309
|
| 6841 | `custbody_sales_stage` | Sales Stage (on opportunity) |
|
|
289
310
|
| 3942 | `custbody_ctc_account` (`CF_ACCOUNT`) | Account |
|
|
@@ -371,6 +392,15 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
|
|
|
371
392
|
|
|
372
393
|
## Change history
|
|
373
394
|
|
|
395
|
+
- 2026-08-19 — Added two SuiteQL mechanics that fail **silently**, both found porting the customer
|
|
396
|
+
account-type sync (TRUE-80206): **column aliases come back lower-cased** (`AS accountTypeLabel` →
|
|
397
|
+
`accounttypelabel`, so a camelCase read is `NULL` with no error — it made a backfill report success while
|
|
398
|
+
writing nothing), and **a List/Record custom field selected raw returns the member's internal id, not its
|
|
399
|
+
label** — always `BUILTIN.DF()`. Documented `custentity_account_type` (internal id 10719, 1–5 label map,
|
|
400
|
+
populated on only 2.7% of customers) in the `customer` section and the custom-field table, and noted that
|
|
401
|
+
`listCustomers()`'s entityid/companyname concatenation is the rule any other writer of a customer name
|
|
402
|
+
must mirror byte-for-byte. Also recorded (elsewhere) that `scriptdeployment` is queryable via SuiteQL but
|
|
403
|
+
absent from the REST record catalog. (kyalamarthi)
|
|
374
404
|
- 2026-07-21 — **Recorded the "one PHP 7.4+ token = total outage" blast-radius lesson** on the PHP
|
|
375
405
|
7.2 compat gotcha (folded in from a retired project-local CLAUDE.md). On 2026-06-10 a single
|
|
376
406
|
`static fn(...)` in `rest.php` parse-errored `App_Api_Netsuite_Rest` on class load and crashed ALL
|
|
@@ -6,8 +6,8 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["mhammontree", "bala"]
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["mhammontree", "bala", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
13
13
|
- dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
|
|
@@ -233,8 +233,37 @@ blocked by the id-divergence hazard in item 2 above, which is why both go to jca
|
|
|
233
233
|
- `item-fulfillments.returnAddressId` — the **column exists** but there is **no Core RecordField**.
|
|
234
234
|
Only needed for the return-label flow.
|
|
235
235
|
|
|
236
|
+
## ⚠ It is not only metadata — the `Forecast/` DDL fan-out is stale too (dev-sandbox, 2026-08-19)
|
|
237
|
+
|
|
238
|
+
The rest of this workflow is about **metadata** rows drifting. The same "someone has to remember to run
|
|
239
|
+
it there" gap applies to plain **DDL**, and on dev-sandbox the `Forecast/` folder looks like it has not
|
|
240
|
+
been applied since roughly **February 2026**:
|
|
241
|
+
|
|
242
|
+
| Column | Migration date | dev-sandbox |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `Employees.departmentId` | 2026-02 | present |
|
|
245
|
+
| `Customers.accountType` | 2026-07-14 | **missing** |
|
|
246
|
+
| `Sales.amountDue` | 2026-06-09 | **missing** |
|
|
247
|
+
| `Opportunities.clickupTaskId` | 2026-07-20 | **missing** |
|
|
248
|
+
|
|
249
|
+
All three missing columns are merged in `dbchanges2` `_main`. Note the shape differs from the `Client/`
|
|
250
|
+
fan-out gap documented above: this is not a few skipped dates, it is a **continuous ~6-month tail**.
|
|
251
|
+
|
|
252
|
+
**Consequence: dev-sandbox cannot be used to test recent Forecast work.** A dry run there dies on the
|
|
253
|
+
missing column, which reads like an application bug and burns an afternoon. Until the `Forecast/` fan-out
|
|
254
|
+
is caught up, test against a **local Forecast fixture** instead — see
|
|
255
|
+
[running worker2 locally](../../worker2/workflows/running-worker2-locally.md). Before assuming a
|
|
256
|
+
`Forecast` column exists in any non-prod environment, check the environment, not the repo.
|
|
257
|
+
|
|
236
258
|
## Change history
|
|
237
259
|
|
|
260
|
+
- 2026-08-19 — Recorded that the drift is **not only metadata**: dev-sandbox's `Forecast/` **DDL** fan-out
|
|
261
|
+
appears unapplied since ~Feb 2026 — it has `Employees.departmentId` (2026-02) but is missing
|
|
262
|
+
`Sales.amountDue` (2026-06-09), `Customers.accountType` (2026-07-14) and `Opportunities.clickupTaskId`
|
|
263
|
+
(2026-07-20), all merged in `_main`. Unlike the `Client/` gap this is a continuous ~6-month tail, not a
|
|
264
|
+
few skipped dates. Practical effect: **dev-sandbox cannot test recent Forecast work** — a dry run fails
|
|
265
|
+
on the missing column and looks like an application bug; use a local Forecast fixture instead. Surfaced
|
|
266
|
+
while testing the customer account-type sync (TRUE-80206). (kyalamarthi)
|
|
238
267
|
- 2026-08-17 (later pass) — Extended the fan-out gap: it **also hits `_modules/<module>/`**, not just
|
|
239
268
|
`Client/`. Beta `Client_Aig` had never run `_modules/netsuite/2026-07-10a - UnitInventoryFields.sql`
|
|
240
269
|
despite `_modules.txt` listing `netsuite`, so `Units` lacked six `c_` columns prod has → 1054/EV-12
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
| [NetSuite ↔ ClickUp / 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/Worker/Clickup.php, worker2/Worker/Clickup/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, _underscore/Model/Forecast/Opportunity.php, test/@dave/approach/TRUE-80044.md, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
30
30
|
| [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, worker2/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Location.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js, 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 |
|
|
31
31
|
| [Toga → NetSuite Sales-Order Push (multi-client, mapping-driven worker)](features/netsuite-salesorder-outbound-push.md) | The **outbound** half of `worker2/Worker/Netsuite/SalesOrder.php` (everything from the `OUTBOUND PUSH (REST)` banner down) pushes a Toga sales order **into** Ne | worker2/Worker/Netsuite/SalesOrder.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php, worker/crons/toga2/prudential/transmissions_to_netsuite.php |
|
|
32
|
-
| [NetSuite Supporting-Record
|
|
32
|
+
| [NetSuite Supporting-Record Backfill Worker (bulk reconciliation recipe)](features/netsuite-supporting-record-backfill-worker.md) | The **bulk counterpart** to the [supporting-record webhook importer](./netsuite-supporting-record-webhook-importer.md). | worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php, worker2/Worker/Netsuite/ItemFulfillableBackfill.php, worker2/Worker/Netsuite/Customer.php, library/app/model/forecast2/customer.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
33
|
+
| [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php |
|
|
33
34
|
| [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
|
|
34
35
|
| [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 |
|
|
35
36
|
| [NYCHH Asset-Tag Backfill (worker2)](features/nychh-asset-tag-backfill.md) | Keeps NYC Health & Hospitals (`Client_Nychh`) unit asset tags and MAC addresses synced from NetSuite. | worker2/Worker/Client/Nychh.php, worker2/Worker/Client/Nychh/AssetTagBackfill.php, worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php, worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php |
|
|
@@ -46,4 +47,5 @@
|
|
|
46
47
|
| [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
|
|
47
48
|
| [WJE Freshservice Sync (worker2)](features/wje-freshservice-sync.md) | WJE ("WJE IT", helpdesk `wje.freshservice.com`) is a **Freshservice**-based help-desk client whose tickets, contacts, assets, groups, categories, and canned res | worker2/Worker/Wje.php, _underscore/Component/Api/Wje/Wje.php, _underscore/Model/Wje/Ticket.php, _underscore/Model/Wje/TicketNote.php, _underscore/Model/Wje/Contact.php, _underscore/Model/Wje/Unit.php, _underscore/Model/Wje/TicketTeam.php, _underscore/Model/Wje/TicketCategory.php, _underscore/Model/Wje/AssetType.php, _underscore/Model/Wje/PredefinedReply.php, library/app/api/wje.php, worker/crons/toga2/wje/import_supporting_records.php, worker/crons/toga2/wje/sync_togasupply_wje.php, worker/crons/notifications/reports/wje/wje_common.php, library/app/systemmonitor/wje.php, dbchanges2/Client_Wje/2024-10-04 - WjeOnboarding.sql |
|
|
48
49
|
| [PHP Runtime Upgrade on Elastic Beanstalk (worker2 8.3 → 8.5 + PhpSpreadsheet 1.x → 3.x)](workflows/php-runtime-upgrade-dependency-audit.md) | The procedure used to move worker2 from **PHP 8.3 to PHP 8.5** on Elastic Beanstalk, and the dependency work that had to land first. | worker2/composer.json, worker2/composer.lock, worker2/Worker/Team/Sprint.php, worker2/Worker/Client/TowFoundation/ProcessReceipts.php, worker2/Worker/Forecast/Import.php |
|
|
50
|
+
| [Running worker2 locally against real NetSuite (and what EB does instead)](workflows/running-worker2-locally.md) | How to boot **worker2** on a developer machine and run a real worker action against the **live NetSuite** account and a **local** `Forecast` database. | worker2/index.php, worker2/composer.json, worker2/.ebextensions/php_include_underscore.config, worker2/.ebextensions/git.json, worker2/.ebextensions/git.php, worker2/Config/production.ini, _underscore/Component/Api/Netsuite/Netsuite.php, dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL |
|
|
49
51
|
| [Ticket → ClickUp Pseudocode Planning (Talos-grounded)](workflows/ticket-to-pseudocode-planning.md) | A repeatable procedure for turning a ClickUp ticket into a reviewed, formatted implementation plan posted back to the ticket's `📝 Pseudocode` custom field. | test/@dave/clickup_md2delta.js |
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-19
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Infrastructure/CloudWatch.php
|
|
@@ -28,7 +28,10 @@ OneUptime string-matches — OneUptime cannot compare numbers on a pushed body.
|
|
|
28
28
|
|
|
29
29
|
The load-bearing design decision this doc records is the **paging (alarm) criteria**: page
|
|
30
30
|
on EB `Degraded`/`Severe`, but **suppress the common "an occasional HTTP 500 tripped
|
|
31
|
-
Degraded" false alarm** unless 5xx errors actually dominate the request mix
|
|
31
|
+
Degraded/Severe" false alarm** unless 5xx errors actually dominate the request mix (by
|
|
32
|
+
share) or reach a real absolute volume. The 5xx gate applies to **both** `Degraded` and
|
|
33
|
+
`Severe`, and only when EB attributes the problem **solely** to 5xx — a mixed cause always
|
|
34
|
+
pages.
|
|
32
35
|
|
|
33
36
|
**Critical behavior:** the method is **non-fatal and never throws** — it always returns a
|
|
34
37
|
string. worker2 has **no DLQ and a 3600s SQS visibility timeout**, so any uncaught 500
|
|
@@ -53,15 +56,33 @@ itself runs non-fatal.
|
|
|
53
56
|
Per environment, `alarm=HIGH` (page) when the EB `HealthStatus` is `Degraded` **or**
|
|
54
57
|
`Severe`. `Suspended` never pages.
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
**
|
|
62
|
-
- **
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
The **5xx gate applies to both `Degraded` and `Severe`.** A non-5xx or mixed-cause
|
|
60
|
+
`Degraded`/`Severe` — and any env with no stated cause — **always pages**; the gate only
|
|
61
|
+
ever suppresses an env EB attributes **solely** to 5xx.
|
|
62
|
+
|
|
63
|
+
- **Non-5xx or mixed-cause `Degraded`/`Severe`** (latency, instances down, a failed deploy,
|
|
64
|
+
or a 5xx trickle *alongside* a real non-5xx failure) **always pages.**
|
|
65
|
+
- **Solely-5xx `Degraded`/`Severe` pages only on a genuine flood** — either:
|
|
66
|
+
- the **absolute** 5xx count in the window is at/above `HTTP_5XX_ALARM_ABSOLUTE_COUNT`
|
|
67
|
+
(default 20), **or**
|
|
68
|
+
- the **5xx share** of requests is at/above `HTTP_5XX_ALARM_RATIO` (default 0.80).
|
|
69
|
+
- **Zero requests in the window → no page** (nothing observed; also avoids divide-by-zero).
|
|
70
|
+
|
|
71
|
+
**Why the gate now covers `Severe` too:** EB escalates an environment to `Severe` precisely
|
|
72
|
+
when the 5xx share is high, so real 5xx floods reach `Severe` *first* and never touch the
|
|
73
|
+
gated `Degraded` branch. When the gate applied only to `Degraded`, the 80% threshold was
|
|
74
|
+
effectively dead — a real 50%-5xx `Severe` event paged despite the setting. Gating both
|
|
75
|
+
tiers makes the threshold actually govern.
|
|
76
|
+
|
|
77
|
+
**Why "solely" 5xx, not "any" 5xx cause:** a mixed-cause env (a 5xx trickle plus a real
|
|
78
|
+
non-5xx failure such as a failed deploy) must not be silenced by a sub-threshold ratio. The
|
|
79
|
+
ratio gate only applies when **every** stated cause references 5xx (and there is at least
|
|
80
|
+
one); anything else pages.
|
|
81
|
+
|
|
82
|
+
**Why an absolute floor in addition to the ratio:** the ratio alone is magnitude-blind —
|
|
83
|
+
60k failing out of 100k reads as 60% and would be suppressed, though it is plainly a flood.
|
|
84
|
+
`HTTP_5XX_ALARM_ABSOLUTE_COUNT` pages such an env regardless of share; tune it to the busiest
|
|
85
|
+
monitored environment's request volume.
|
|
65
86
|
|
|
66
87
|
### Fail-safe: a blind read pages, never reads healthy
|
|
67
88
|
|
|
@@ -76,18 +97,23 @@ signal — same alarm-vs-probe split as the multi-client monitors in
|
|
|
76
97
|
| Constant | Default | Meaning |
|
|
77
98
|
|---|---|---|
|
|
78
99
|
| `HEALTH_ALARM_STATUSES` | `['Degraded','Severe']` | HealthStatus values that page |
|
|
79
|
-
| `HTTP_5XX_ALARM_RATIO` | `0.80` | 5xx share (0.0–1.0) at/above which a 5xx
|
|
80
|
-
| `
|
|
100
|
+
| `HTTP_5XX_ALARM_RATIO` | `0.80` | 5xx share (0.0–1.0) at/above which a solely-5xx `Degraded`/`Severe` pages |
|
|
101
|
+
| `HTTP_5XX_ALARM_ABSOLUTE_COUNT` | `20` | Absolute 5xx count in the window at/above which a solely-5xx `Degraded`/`Severe` pages regardless of share |
|
|
81
102
|
| `HTTP_5XX_CAUSE_MARKERS` | `['5xx','http 5']` | Case-insensitive substrings identifying a 5xx-attributed EB `Cause` |
|
|
82
103
|
|
|
83
104
|
### Implementation shape
|
|
84
105
|
|
|
85
106
|
- `shouldPageForHealth(healthStatus, causes, applicationMetrics): [bool, ?float]` — returns
|
|
86
107
|
the page decision and the observed 5xx ratio.
|
|
87
|
-
- `
|
|
88
|
-
the
|
|
108
|
+
- `causesAttributeSolelyTo5xx(causes): bool` — true only when **every** EB `Cause` references
|
|
109
|
+
5xx **and there is at least one** cause. Replaces the former `causesAttributeTo5xx()`, which
|
|
110
|
+
returned true if *any* cause mentioned 5xx and so let a mixed-cause env be gated.
|
|
111
|
+
- `causeIsFivexx(cause): bool` — helper that substring-matches `HTTP_5XX_CAUSE_MARKERS`
|
|
112
|
+
against a single EB `Cause` string (case-insensitive); `causesAttributeSolelyTo5xx` requires
|
|
113
|
+
it to hold for all causes.
|
|
89
114
|
- The ratio is computed as `StatusCodes.Status5xx / RequestCount` from `ApplicationMetrics`
|
|
90
|
-
(raw counts — see below), **not** by parsing the percentage out of the `Causes` text.
|
|
115
|
+
(raw counts — see below), **not** by parsing the percentage out of the `Causes` text. The
|
|
116
|
+
absolute-count floor reads `StatusCodes.Status5xx` directly.
|
|
91
117
|
- `describeEnvironmentHealth` is called with
|
|
92
118
|
`AttributeNames = ['HealthStatus','Status','Color','Causes','ApplicationMetrics']`.
|
|
93
119
|
- Per-environment report + OneUptime payload now include the per-env `alarm` token and the
|
|
@@ -147,9 +173,10 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
|
|
|
147
173
|
**whole run** via the outer backstop instead of paging just that region's envs as blind.
|
|
148
174
|
Candidate follow-up now that blind reads page.
|
|
149
175
|
- **No first-party test harness exists in worker2** (all tests are vendor/).
|
|
150
|
-
`shouldPageForHealth()` and `
|
|
151
|
-
(ratio at exactly 0.80; count 19 vs 20; `Severe` over a 5xx cause;
|
|
152
|
-
metrics) — deferred pending a
|
|
176
|
+
`shouldPageForHealth()` and `causesAttributeSolelyTo5xx()` are pure and ideal to unit-test
|
|
177
|
+
(ratio at exactly 0.80; absolute count 19 vs 20; `Severe` over a solely-5xx cause; a
|
|
178
|
+
mixed 5xx + non-5xx cause; empty causes; null metrics; zero requests) — deferred pending a
|
|
179
|
+
harness.
|
|
153
180
|
|
|
154
181
|
## Gotchas / known issues
|
|
155
182
|
|
|
@@ -164,8 +191,28 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
|
|
|
164
191
|
— never parse the percentage out of `Causes`.**
|
|
165
192
|
- **`oneuptimeUrl` is a push credential** — it arrives as a cron parameter; never log it or
|
|
166
193
|
record its value in a doc.
|
|
194
|
+
- **Near-idle ratio edge (residual, accepted).** With the tiny-sample guard removed, a
|
|
195
|
+
near-idle env whose window holds a tiny all-error sample (e.g. its only request being a
|
|
196
|
+
500 = 100%) still trips the ratio and pages. Optional future guard: require a minimum 5xx
|
|
197
|
+
count before the ratio applies (the absolute floor gates high volume, not this low-volume
|
|
198
|
+
edge).
|
|
167
199
|
|
|
168
200
|
## Change history
|
|
201
|
+
- 2026-08-19 — Overhauled the 5xx alarm gating in `shouldPageForHealth()`. The 80% share
|
|
202
|
+
gate now applies to **both** `Degraded` and `Severe` (was `Degraded`-only, which left the
|
|
203
|
+
threshold dead because EB escalates real 5xx floods straight to `Severe` — a 50%-5xx
|
|
204
|
+
`Severe` had been paging despite the 0.80 setting). This Severe-gating trade-off was made
|
|
205
|
+
deliberately with an independent architecture second opinion on record (cto returned
|
|
206
|
+
DISAGREE-WITH-ALTERNATIVE; developer accepted). Narrowed 5xx classification: replaced
|
|
207
|
+
`causesAttributeTo5xx()` (any cause mentions 5xx) with `causesAttributeSolelyTo5xx()` (every
|
|
208
|
+
cause references 5xx, ≥1) + a `causeIsFivexx()` helper, so a mixed cause (5xx trickle beside
|
|
209
|
+
a real non-5xx failure) always pages — a security-review gap the Severe change would have
|
|
210
|
+
widened. Added `HTTP_5XX_ALARM_ABSOLUTE_COUNT` (20): a solely-5xx env pages regardless of
|
|
211
|
+
share once the absolute 5xx count reaches the floor (closes the ratio's magnitude-blindness,
|
|
212
|
+
e.g. 60k of 100k). Removed `HTTP_5XX_MIN_REQUEST_COUNT` (was 20) and its tiny-sample
|
|
213
|
+
page-anyway guard — a small number of 5xx is now judged purely on the ratio; below the
|
|
214
|
+
absolute floor, page only if share ≥ 0.80, and zero requests → no page. Accepted residual:
|
|
215
|
+
a near-idle env with a tiny all-error sample still trips the ratio. (jcardinal)
|
|
169
216
|
- 2026-08-17 — Created. Documented the `ElasticBeanstalkHealth` alarm/paging criteria (page
|
|
170
217
|
on `Degraded`/`Severe`; suppress a 5xx-driven `Degraded` unless the 5xx share ≥
|
|
171
218
|
`HTTP_5XX_ALARM_RATIO` 0.80 with an `HTTP_5XX_MIN_REQUEST_COUNT` 20 tiny-sample guard;
|
|
@@ -178,5 +225,3 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
|
|
|
178
225
|
covers the reads). Recorded the reverted `['Severe']`-only + deployment-failure-detector
|
|
179
226
|
direction and the accepted residual gap (a deploy that fails without degrading health).
|
|
180
227
|
(jcardinal)
|
|
181
|
-
</content>
|
|
182
|
-
</invoke>
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["dfranks"]
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["dfranks", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
13
13
|
- worker2/Worker/Netsuite/Opportunity.php
|
|
@@ -276,6 +276,22 @@ None — platform-wide Forecast sync.
|
|
|
276
276
|
enqueuer's `scriptnote` AUDIT log going quiet and the missing opps having zero log entries. When opps go
|
|
277
277
|
missing, **check the enqueuer deployment's Status AND Audience (All Roles/All Employees = T)**, not just
|
|
278
278
|
"is it Released." Backfill the gap window with `trueup_opportunities.php`.
|
|
279
|
+
- **⚠ The enqueuer is deployed PER RECORD TYPE, and a record type left at `Testing` / `isdeployed = F`
|
|
280
|
+
enqueues NOTHING — forever, silently.** Same failure family as the empty-audience incident above, and it
|
|
281
|
+
is the default state of a new deployment (runbook §3: "Status Testing — fires only for you → safe"),
|
|
282
|
+
so it is missed by *forgetting* §6's promotion step rather than by doing anything wrong.
|
|
283
|
+
Live case, found 2026-08-19 (TRUE-80206): `customscript_ue_amq_enqueue` for **Customer** (deployment
|
|
284
|
+
internal id **9**, script id `customdeploy9`) sat at `status = TESTING` / `isdeployed = F` while the
|
|
285
|
+
other **15** record types were `RELEASED` / `T`. Consequence: **zero** `Netsuite/Customer/*` jobs had
|
|
286
|
+
ever run in production — the handler, the model and the migration had all been merged since 2026-07-14
|
|
287
|
+
and every one of the 9,206 rows was still NULL. Nothing errored, because nothing ran.
|
|
288
|
+
- **`Testing` fires only for the deploying user**, and `isdeployed = F` stops it entirely — so a
|
|
289
|
+
developer testing in the UI sees the sync "work" while production gets nothing.
|
|
290
|
+
- **Diagnose it with SuiteQL:** the `scriptdeployment` table exposes `status` / `isdeployed` per
|
|
291
|
+
deployment, and querying all record types side by side is what made the one-of-sixteen outlier
|
|
292
|
+
obvious. **`scriptdeployment` is NOT in the NetSuite REST record catalog**, so you can *read* it via
|
|
293
|
+
SuiteQL but you **cannot change it via the API** — promotion is a UI (or SDF) action only.
|
|
294
|
+
- Whenever a record type "isn't syncing", check this **before** reading any PHP.
|
|
279
295
|
- **NetSuite SuiteQL renders timestamps in TWO different zones — do not compare them naïvely.** Stored
|
|
280
296
|
datetime fields (a custom record's `created`, transaction dates, `systemnote.date`) and the NetSuite UI
|
|
281
297
|
render in the **account/user TZ (Eastern here)**, but **`SYSDATE` and `scriptnote.date` render in
|
|
@@ -486,6 +502,14 @@ deprecated** for production opportunity code.
|
|
|
486
502
|
blocked on the Aaron stakeholder decision noted above.
|
|
487
503
|
|
|
488
504
|
## Change history
|
|
505
|
+
- 2026-08-19 — Recorded a second, worse instance of the "`Released` ≠ active" enqueuer-deployment failure
|
|
506
|
+
family: the AMQ enqueuer is deployed **per record type**, and **Customer** (`customdeploy9`, deployment
|
|
507
|
+
internal id 9) was still at `status = TESTING` / `isdeployed = F` while the other 15 types were
|
|
508
|
+
`RELEASED`/`T` — so **no `Netsuite/Customer/*` job had ever run in production** since the code merged on
|
|
509
|
+
2026-07-14 (root cause of TRUE-80206; resolved by setting Released + Deployed in the NetSuite UI, after
|
|
510
|
+
which the first-ever job succeeded in 0.9 s). Added the diagnostic: the `scriptdeployment` table is
|
|
511
|
+
readable via **SuiteQL** (comparing all record types side by side is what exposed the outlier) but is
|
|
512
|
+
**absent from the REST record catalog**, so the promotion can only be done in the UI/SDF. (kyalamarthi)
|
|
489
513
|
- 2026-07-15 — **Decided the atomic ClickUp-task dedup approach (TRUE-80044) — planning/rework, no code
|
|
490
514
|
shipped.** The non-atomic find-then-create lets concurrent NS opportunity webhooks create duplicate
|
|
491
515
|
ClickUp tasks. Decided fix: a race-safe **conditional-UPDATE claim** on a new `clickupTaskId` column of
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: NetSuite Supporting-Record Backfill Worker (bulk reconciliation recipe)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["kyalamarthi"]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php
|
|
13
|
+
- worker2/Worker/Netsuite/ItemFulfillableBackfill.php
|
|
14
|
+
- worker2/Worker/Netsuite/Customer.php
|
|
15
|
+
- library/app/model/forecast2/customer.php
|
|
16
|
+
- worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
|
|
17
|
+
related:
|
|
18
|
+
- ./netsuite-supporting-record-webhook-importer.md
|
|
19
|
+
- ../workflows/running-worker2-locally.md
|
|
20
|
+
- ../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
The **bulk counterpart** to the [supporting-record webhook importer](./netsuite-supporting-record-webhook-importer.md).
|
|
26
|
+
The webhook keeps a `Forecast` column current *going forward*, one record at a time. It cannot fix
|
|
27
|
+
history, and it never fires for a NetSuite **bulk** change. A backfill worker re-derives one column
|
|
28
|
+
for **every** row from NetSuite in batches, and is the reconciliation path after any mass edit.
|
|
29
|
+
|
|
30
|
+
Two exist and they share one recipe: `Netsuite/ItemFulfillableBackfill/…` (the original) and
|
|
31
|
+
`Netsuite/CustomerAccountTypeBackfill/Backfill` (TRUE-80206). Treat the recipe as fill-in-the-blanks —
|
|
32
|
+
the interesting decisions are already made.
|
|
33
|
+
|
|
34
|
+
## Key files / entry points
|
|
35
|
+
|
|
36
|
+
- **`worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php`** —
|
|
37
|
+
`abstract class _Worker_Netsuite_CustomerAccountTypeBackfill`.
|
|
38
|
+
Action **`Netsuite/CustomerAccountTypeBackfill/Backfill`**, parameters:
|
|
39
|
+
- `dryRun` — **defaults to `true`.** A caller must opt *in* to writing (`dryRun=false`).
|
|
40
|
+
- `netsuiteInternalId` — optional; restricts the run to a single customer (the fast way to
|
|
41
|
+
verify one record end-to-end without a full pass).
|
|
42
|
+
- **`worker2/Worker/Netsuite/ItemFulfillableBackfill.php`** — the file to clone for the next one.
|
|
43
|
+
|
|
44
|
+
## How it works (the recipe)
|
|
45
|
+
|
|
46
|
+
1. **Page the local side.** Read the `Forecast` rows to reconcile in pages of **1,000**.
|
|
47
|
+
2. **Ask NetSuite in batches.** One SuiteQL query per batch of **≤ 500** ids
|
|
48
|
+
(`… WHERE id IN (<ids>)`). Ids are local PKs / NetSuite internal ids cast with `(int)` — the
|
|
49
|
+
same cast-only injection control the webhook handlers use (there is no placeholder binding on
|
|
50
|
+
the SuiteQL REST endpoint).
|
|
51
|
+
3. **Write back in batches of ≤ 500**, skipping rows already correct.
|
|
52
|
+
4. **`dryRun` defaults to `true`** so an accidental invocation is a report, not a mutation.
|
|
53
|
+
5. **Return a countable summary on EVERY path** (scanned / found in NetSuite / already correct /
|
|
54
|
+
updated / skipped) — worker2 actions must return a result on both the success and the
|
|
55
|
+
nothing-to-do path, or the job looks hung.
|
|
56
|
+
6. **Idempotent by construction.** An immediate re-run must report *N already correct / 0 updated*.
|
|
57
|
+
That re-run is the acceptance test.
|
|
58
|
+
|
|
59
|
+
### The dry run doubles as the field-type probe
|
|
60
|
+
|
|
61
|
+
`CustomerAccountTypeBackfill::detectAccountTypeExpression()` issues **one** probe query using
|
|
62
|
+
`BUILTIN.DF(<field>)` and falls back to the raw column if that errors, then prints the expression it
|
|
63
|
+
chose **and the values it would store**. So the first dry run answers "is this custom field a
|
|
64
|
+
List/Record or a scalar?" before a single row is written. Copy this when backfilling any custom
|
|
65
|
+
field whose type you have not personally confirmed — guessing wrong stores internal ids
|
|
66
|
+
(see the [List/Record trap](./netsuite-supporting-record-webhook-importer.md)).
|
|
67
|
+
|
|
68
|
+
## Why this worker exists — do not treat it as a throwaway script
|
|
69
|
+
|
|
70
|
+
`Forecast.Customers.accountType` has **no nightly backstop**. `App_Model_Forecast2_Customer`
|
|
71
|
+
(`library/app/model/forecast2/customer.php`) does not declare the column, so the 2 AM
|
|
72
|
+
"Forecast 2.0 — Import supporting records" cron can neither write it nor clobber it. That omission
|
|
73
|
+
is deliberate (the model-omits-externally-managed-columns rule), but it means **the webhook is the
|
|
74
|
+
only ongoing writer** — and:
|
|
75
|
+
|
|
76
|
+
- **Bulk NetSuite changes fire no User Event.** A Mass Update, or a CSV import without
|
|
77
|
+
*"Run Server SuiteScript and Trigger Workflows"*, updates the record without enqueuing anything.
|
|
78
|
+
The webhook never sees it.
|
|
79
|
+
- **Therefore: after any bulk reclassification in NetSuite, re-run this backfill.** It is the only
|
|
80
|
+
thing that will bring `Forecast` back into agreement.
|
|
81
|
+
|
|
82
|
+
## Production results (2026-08-19)
|
|
83
|
+
|
|
84
|
+
- **248 of 9,183** NetSuite customers have an Account Type set — **2.7%**. The other 97% are legitimately
|
|
85
|
+
blank; do not read "blank" as "broken".
|
|
86
|
+
- Full backfill: **248 updated in 8.5 s**. Immediate re-run: **248 already correct / 0 updated**.
|
|
87
|
+
|
|
88
|
+
## Gotchas / known issues
|
|
89
|
+
|
|
90
|
+
- **⚠ The 1.0 nightly cron is effectively INSERT-ONLY for customers — latent bug, NOT fixed.**
|
|
91
|
+
`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php:256-259` guards the update with
|
|
92
|
+
`name != … && consolidatedCustomerId != …` where it plainly means `||`, so an existing row is only
|
|
93
|
+
rewritten when **both** values changed. Renames therefore never propagate from NetSuite. Do not
|
|
94
|
+
assume the nightly cron will heal a stale `Forecast.Customers` row — it will not.
|
|
95
|
+
Evidence (prod, 2026-08-19): Forecast holds **9,206** customers vs **9,183** in NetSuite, i.e. 23
|
|
96
|
+
stale rows that were never pruned either.
|
|
97
|
+
- **`dryRun` is `true` by default** — a run that "did nothing" almost certainly just needed
|
|
98
|
+
`dryRun=false`. Read the returned summary before concluding the data was already correct.
|
|
99
|
+
- **SuiteQL lower-cases every column alias**, so a camelCase alias read back as camelCase is silently
|
|
100
|
+
`NULL` — this broke the first version of this backfill (every customer misreported as "blank in
|
|
101
|
+
NetSuite" while the job reported success). See the
|
|
102
|
+
[importer gotchas](./netsuite-supporting-record-webhook-importer.md).
|
|
103
|
+
- Do not drive a local test of these through `_Worker::runTask` — the dev config's worker queue URL
|
|
104
|
+
points at production SQS. Call the method in-process; see
|
|
105
|
+
[running worker2 locally](../workflows/running-worker2-locally.md).
|
|
106
|
+
|
|
107
|
+
## Change history
|
|
108
|
+
|
|
109
|
+
- 2026-08-19 — Created after building `_Worker_Netsuite_CustomerAccountTypeBackfill` (TRUE-80206) on
|
|
110
|
+
the `ItemFulfillableBackfill` pattern: recorded the batching recipe (page 1,000 / SuiteQL `IN` 500 /
|
|
111
|
+
UPDATE 500), the `dryRun`-defaults-to-true and countable-summary-on-every-path contract, and the
|
|
112
|
+
**dry-run-as-field-type-probe** trick (`detectAccountTypeExpression()` probes `BUILTIN.DF` once and
|
|
113
|
+
falls back to the raw column). Recorded **why the worker must be kept**: `Forecast.Customers.accountType`
|
|
114
|
+
has no nightly backstop (the 1.0 model does not declare it) and bulk NetSuite edits fire no User Event,
|
|
115
|
+
so this is the only reconciliation path. Also recorded the latent 1.0 cron `&&`-should-be-`||` change
|
|
116
|
+
guard that makes the customer cron insert-only (9,206 Forecast rows vs 9,183 in NetSuite). Prod run:
|
|
117
|
+
248/9,183 (2.7%) updated in 8.5 s, re-run idempotent. (kyalamarthi)
|
|
118
|
+
|
|
119
|
+
## Related docs
|
|
120
|
+
|
|
121
|
+
- [NetSuite Supporting-Record Webhook Importer](./netsuite-supporting-record-webhook-importer.md) — the
|
|
122
|
+
real-time single-record sibling; the Customers variant and the List/Record `BUILTIN.DF` rule live there.
|
|
123
|
+
- [Running worker2 locally against real NetSuite](../workflows/running-worker2-locally.md) — how this was
|
|
124
|
+
tested before it touched production.
|
|
125
|
+
- [NetSuite SuiteQL/REST API Reference](../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md)
|
|
126
|
+
— SuiteQL mechanics, `BUILTIN.DF`, the lower-cased-alias rule, and the custom-field map.
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["dfranks", "bala"]
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["dfranks", "bala", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/Employee.php
|
|
13
13
|
- worker2/Worker/Netsuite/Account.php
|
|
@@ -23,8 +23,14 @@ files:
|
|
|
23
23
|
- test/@dave/test_account_lifecycle.php
|
|
24
24
|
- test/@dave/test_classification_lifecycle.php
|
|
25
25
|
- test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
|
|
26
|
+
- test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md
|
|
26
27
|
- worker/crons/toga2/forecast2/import_supporting_records.php
|
|
28
|
+
- worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
|
|
29
|
+
- worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php
|
|
30
|
+
- library/app/api/netsuite/rest.php
|
|
31
|
+
- library/app/model/forecast2/customer.php
|
|
27
32
|
related:
|
|
33
|
+
- ./netsuite-supporting-record-backfill-worker.md
|
|
28
34
|
- ./netsuite-opportunity-sync.md
|
|
29
35
|
- ./netsuite-salesorder-open-orders-sync.md
|
|
30
36
|
- ../../_underscore/features/forecast-sale-import.md
|
|
@@ -76,15 +82,24 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
|
|
|
76
82
|
*trivially* on merge — keep every helper method).
|
|
77
83
|
5. **Enqueuer:** add `'<record>':'<record>'` to `RECORD_TYPE_MAP` in
|
|
78
84
|
`test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js` (account/customer/employee/
|
|
79
|
-
classification entries already exist).
|
|
85
|
+
classification entries already exist). **Then promote that record type's `customscript_ue_amq_enqueue`
|
|
86
|
+
deployment to `Status = Released` AND `Deployed = T` in NetSuite** (runbook §6, "Before production").
|
|
87
|
+
A new deployment defaults to `Testing` / `isdeployed = F`, which fires for **nobody** — the code can be
|
|
88
|
+
merged and correct for weeks while zero jobs ever run. This step was missed for Customer and was the
|
|
89
|
+
root cause of TRUE-80206; see [the enqueuer deployment gotchas](./netsuite-opportunity-sync.md).
|
|
90
|
+
**The port is not done until you have seen a real job for the new record type in `Core.WorkerJobs`.**
|
|
80
91
|
6. **e2e harness** `test/@dave/test_<record>_lifecycle.php`: clone an existing one. It drives NetSuite
|
|
81
92
|
REST (check/create/get/update/delete) + the in-process worker verbs (post/put/verb-delete) + a local
|
|
82
93
|
dump of the resulting `Forecast` rows. Boots via chdir `worker2` + `require index.php`.
|
|
83
94
|
7. **Cron:** leave the legacy `SHOULD_SYNC_<X>` daily pull **ON** as a backstop (and, for self-ref
|
|
84
95
|
records, the parent/supervisor backfill). It shares `import_supporting_records.php` with the other
|
|
85
96
|
sections, so **never disable the wrapper.**
|
|
97
|
+
⚠ **But do not assume the backstop covers your new column** — see *the backstop is narrower than it
|
|
98
|
+
looks* under Gotchas. A column the 1.0 model does not declare has **no** nightly writer at all, and
|
|
99
|
+
for Customers the cron is effectively insert-only. When there is no backstop, ship a
|
|
100
|
+
[backfill worker](./netsuite-supporting-record-backfill-worker.md) with the feature.
|
|
86
101
|
|
|
87
|
-
##
|
|
102
|
+
## Recurring variants
|
|
88
103
|
|
|
89
104
|
- **Self-referential parent/supervisor FK** (`Employees.supervisorEmployeeId`,
|
|
90
105
|
`Classifications.parentClassificationId`) = **RESOLVE-OR-NULL, never recursive upsert.** Resolve the
|
|
@@ -98,9 +113,38 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
|
|
|
98
113
|
an unmapped value passes through raw. **Keep the const in sync with the shim.**
|
|
99
114
|
- **Name from a hierarchical NetSuite `name`/`fullname`** = the **leaf segment**
|
|
100
115
|
(`explode(' : ')` + `array_pop`), matching the SOAP/cron/Item resolver.
|
|
116
|
+
- **List/Record custom field → `BUILTIN.DF()` is MANDATORY** (Customers `accountType`). Selecting a
|
|
117
|
+
List/Record custom field **raw** returns the list member's **internal id**, not its label, and nothing
|
|
118
|
+
errors — you just store `3`. Select `BUILTIN.DF(<field>)` instead. Concretely:
|
|
119
|
+
`custentity_account_type` (NetSuite Entity Field "Account Type", internal id **10719**) maps
|
|
120
|
+
**1 = Strategic Emerging · 2 = Not Strategic · 3 = Strategic Growth · 4 = Strategic Maintain ·
|
|
121
|
+
5 = Strategic Draining**. Power BI reads `Forecast.Customers.accountType` directly, so a raw id
|
|
122
|
+
renders as `3` on the report. Only **248 of 9,183** customers (2.7%) have the field set at all —
|
|
123
|
+
mostly-blank is correct, not a symptom. If you are unsure of a custom field's type, let the
|
|
124
|
+
[backfill worker's dry run probe it](./netsuite-supporting-record-backfill-worker.md).
|
|
125
|
+
- **A column with TWO writers must agree byte-for-byte on format** (Customers `name`). SOAP returned a
|
|
126
|
+
single `entityId` of the form `"<entityid> <companyname>"`; SuiteQL splits those into two columns, so
|
|
127
|
+
the obvious `SELECT entityid` stores `"6096"` where the legacy 1.0 daily cron stores
|
|
128
|
+
`"6096 ODP Veyer (B2B)"`. Both write `Forecast.Customers.name`, so shipping the bare form would have
|
|
129
|
+
silently truncated the ~6 customer names a day the webhook touches. `Customer.php` therefore has a
|
|
130
|
+
private **`buildCustomerName()`** that mirrors `App_Api_Netsuite_Rest::listCustomers()`
|
|
131
|
+
(`library/app/api/netsuite/rest.php`) **including its "entityid identical to companyname → emit it
|
|
132
|
+
once" rule**. Verified byte-identical against four production-sourced names, one with an apostrophe.
|
|
133
|
+
**Before porting any record whose legacy cron still runs, diff the two writers' output on real rows.**
|
|
101
134
|
|
|
102
135
|
## Gotchas / known issues
|
|
103
136
|
|
|
137
|
+
- **⚠ SuiteQL LOWER-CASES every column alias — reading it back in camelCase silently yields `NULL`.**
|
|
138
|
+
`SELECT … AS accountTypeLabel` returns the key **`accounttypelabel`**; `$row->accountTypeLabel` is then
|
|
139
|
+
`NULL` with no error, no warning and no exception. Confirmed empirically on this account
|
|
140
|
+
(`BUILTIN.DF(sd.recordtype) AS recordTypeName` came back as `recordtypename`). **Use all-lowercase
|
|
141
|
+
aliases, or read the lower-cased key.** This has now caused two separate bugs:
|
|
142
|
+
- it broke the first cut of the account-type backfill — every customer was misreported as "blank in
|
|
143
|
+
NetSuite" **while the job reported success**; only testing caught it;
|
|
144
|
+
- **still open, not fixed:** `worker2/Worker/Netsuite/Customer.php` (~line 147) selects
|
|
145
|
+
`BUILTIN.DF(custentity10) AS consolidatedName` and reads `$record->consolidatedName`, so
|
|
146
|
+
**consolidated-customer names are never set or refreshed** by the webhook. Fix it the next time that
|
|
147
|
+
handler is opened.
|
|
104
148
|
- **SuiteQL over the REST client has NO server-side placeholder binding.** `_Component_Api_Netsuite::send`
|
|
105
149
|
passes the query string as-is, so the **`(int)` cast on an already-int id is the injection control** —
|
|
106
150
|
the same codebase-wide idiom used by `lookupId()` and the Item/Opportunity/Employee handlers. A
|
|
@@ -114,14 +158,44 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
|
|
|
114
158
|
no migration needed.
|
|
115
159
|
- **`save()` does not diff** — see recipe step 2: the change-detect guard before `save()` is what makes
|
|
116
160
|
a no-change webhook a true no-op.
|
|
117
|
-
- **⚠
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
- **⚠ The backstop is narrower than it looks — some columns have NO nightly writer at all.**
|
|
162
|
+
Recipe step 7 says leave the legacy cron on as a backstop, and that is right, but the backstop only
|
|
163
|
+
covers columns the **1.0** model declares. `App_Model_Forecast2_Customer`
|
|
164
|
+
(`library/app/model/forecast2/customer.php`) does not declare `accountType`, so the 2 AM
|
|
165
|
+
"Forecast 2.0 — Import supporting records" cron can neither write **nor clobber** it — the webhook is
|
|
166
|
+
the **only** ongoing writer. Worse, for Customers the cron is effectively **insert-only**: its change
|
|
167
|
+
guard (`common_import_sales_from_netsuite.php:256-259`) uses `name != … && consolidatedCustomerId != …`
|
|
168
|
+
where it means `||`, so an existing row is rewritten only when *both* changed and renames never
|
|
169
|
+
propagate (prod: 9,206 Forecast customers vs 9,183 in NetSuite). **Latent bug, not fixed.** And because
|
|
170
|
+
a NetSuite **Mass Update / CSV import without "Run Server SuiteScript"** fires no User Event, a bulk
|
|
171
|
+
reclassification reaches neither writer. Ship a
|
|
172
|
+
[backfill worker](./netsuite-supporting-record-backfill-worker.md) for any column in this position and
|
|
173
|
+
re-run it after every bulk change.
|
|
174
|
+
- **⚠ Unrelated observation, production, updated 2026-08-19: the item job types are still at 0% success,
|
|
175
|
+
the cause is now known, and the volume is growing.** `Core.WorkerJobs` in prod shows
|
|
176
|
+
`Netsuite/InventoryItem/post|put` at **~1,163 failures / 0 successes**, all with
|
|
177
|
+
`watchdog: exceeded maxExecutionTime without completing`, still firing daily; `NonInventoryItem` (55),
|
|
178
|
+
`ItemGroup` (9) and `ServiceItem` (2) are likewise at 0%. The 2026-08-10 snapshot of the same audit was
|
|
179
|
+
`InventoryItem/post` 266 / `InventoryItem/put` 1,014 / `ItemGroup/post` 5. **Still not investigated** —
|
|
180
|
+
recorded as a standing flag for whoever owns the item importers. The failure string points at the
|
|
181
|
+
watchdog/execution-time budget rather than at NetSuite or the data.
|
|
122
182
|
|
|
123
183
|
## Change history
|
|
124
184
|
|
|
185
|
+
- 2026-08-19 — **Customers went live for the first time (TRUE-80206)** and produced four additions.
|
|
186
|
+
(1) **Root cause of the whole ticket:** the `customscript_ue_amq_enqueue` deployment for Customer
|
|
187
|
+
(deployment internal id 9, `customdeploy9`) sat at `Status = TESTING` / `isdeployed = F` while all 15
|
|
188
|
+
other record types were RELEASED/T — so **zero** `Netsuite/Customer/*` jobs had ever run in production
|
|
189
|
+
and all 9,206 rows were NULL, despite code, model and migration being merged since 2026-07-14. Recipe
|
|
190
|
+
step 5 now carries the promote-the-deployment step and the "not done until you see a real job" rule.
|
|
191
|
+
(2) **List/Record custom fields need `BUILTIN.DF()`** — the raw column stores the internal id;
|
|
192
|
+
documented `custentity_account_type` (id 10719) and its 1–5 label map. (3) **SuiteQL lower-cases column
|
|
193
|
+
aliases**, silently returning `NULL` for a camelCase read — it broke the backfill's first cut and is
|
|
194
|
+
**still live** in `Customer.php`'s `consolidatedName`. (4) **Two writers on one column must agree
|
|
195
|
+
byte-for-byte** — added `buildCustomerName()` mirroring `App_Api_Netsuite_Rest::listCustomers()` so the
|
|
196
|
+
webhook stops short of truncating names the 1.0 cron stores concatenated. Also recorded that the legacy
|
|
197
|
+
cron is **not** a universal backstop (no `accountType` in the 1.0 model; `&&`-should-be-`||` change
|
|
198
|
+
guard makes it insert-only) and refreshed the 0%-success item-job flag. (kyalamarthi)
|
|
125
199
|
- 2026-08-10 — Flagged a **production health observation** (not investigated): prod `Core.WorkerJobs`
|
|
126
200
|
has `Netsuite/InventoryItem/post` (266), `Netsuite/InventoryItem/put` (1,014) and
|
|
127
201
|
`Netsuite/ItemGroup/post` (5) at **0% success**, still running daily and apparently unmonitored.
|
|
@@ -137,6 +211,8 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
|
|
|
137
211
|
|
|
138
212
|
## Related docs
|
|
139
213
|
|
|
214
|
+
- [NetSuite Supporting-Record Backfill Worker](./netsuite-supporting-record-backfill-worker.md) — the bulk
|
|
215
|
+
reconciliation counterpart; ship one whenever the legacy cron does not back your column.
|
|
140
216
|
- [NetSuite → TOGA Opportunity Sync](./netsuite-opportunity-sync.md) — header+children transaction sibling.
|
|
141
217
|
- [NetSuite → Forecast Open-Orders Sync](./netsuite-salesorder-open-orders-sync.md).
|
|
142
218
|
- [Forecast.Sales NetSuite import engine](../../_underscore/features/forecast-sale-import.md) — the
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Running worker2 locally against real NetSuite (and what EB does instead)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: ["kyalamarthi"]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/index.php
|
|
13
|
+
- worker2/composer.json
|
|
14
|
+
- worker2/.ebextensions/php_include_underscore.config
|
|
15
|
+
- worker2/.ebextensions/git.json
|
|
16
|
+
- worker2/.ebextensions/git.php
|
|
17
|
+
- worker2/Config/production.ini
|
|
18
|
+
- _underscore/Component/Api/Netsuite/Netsuite.php
|
|
19
|
+
- dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL
|
|
20
|
+
related:
|
|
21
|
+
- ../features/netsuite-supporting-record-backfill-worker.md
|
|
22
|
+
- ../features/netsuite-supporting-record-webhook-importer.md
|
|
23
|
+
- ../../_underscore/features/cli-script-bootstrap.md
|
|
24
|
+
- ../../dbchanges2/workflows/nonprod-metadata-drift-repair.md
|
|
25
|
+
- ../../api2/features/environment-variable-drives-underscore-branch.md
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Summary
|
|
29
|
+
|
|
30
|
+
How to boot **worker2** on a developer machine and run a real worker action against the **live
|
|
31
|
+
NetSuite** account and a **local** `Forecast` database. Written from testing the customer
|
|
32
|
+
account-type work (TRUE-80206), where dev-sandbox was unusable (see the schema gotcha below) and
|
|
33
|
+
production was obviously not an option.
|
|
34
|
+
|
|
35
|
+
Five things have to be true, and four of them are supplied by `.ebextensions` on Elastic Beanstalk
|
|
36
|
+
— which is exactly why they are invisible until you try to run the app locally. Each step below
|
|
37
|
+
pairs the local move with the EB mechanism it replaces.
|
|
38
|
+
|
|
39
|
+
> For a *bare CLI script* that wants the 2.0 framework (rather than the worker app itself), use
|
|
40
|
+
> [Running 2.0 code from a bare CLI script](../../_underscore/features/cli-script-bootstrap.md) —
|
|
41
|
+
> its transaction and `ENVIRONMENT` traps apply here too.
|
|
42
|
+
|
|
43
|
+
## Steps
|
|
44
|
+
|
|
45
|
+
1. **`composer install` — `vendor/` is gitignored.** A fresh checkout has no dependencies.
|
|
46
|
+
If the machine lacks `ext-zip`, install with `--ignore-platform-req=ext-zip`; the only consumer
|
|
47
|
+
is phpspreadsheet, which a NetSuite/Forecast action never touches.
|
|
48
|
+
|
|
49
|
+
2. **Make `_underscore` resolvable on the include path.** `worker2/index.php` is just
|
|
50
|
+
`require '_underscore.php'`, resolved through PHP's `include_path` — there is no relative path
|
|
51
|
+
and no composer entry to follow. Locally, prepend your `_underscore` checkout with
|
|
52
|
+
`set_include_path()` in the harness before the require.
|
|
53
|
+
*On EB:* `worker2/.ebextensions/php_include_underscore.config` writes the same entry into the
|
|
54
|
+
PHP config, and `.ebextensions/git.php` clones `_underscore` next to the app at deploy time
|
|
55
|
+
(branch = `_` + lowercased `ENVIRONMENT`, per
|
|
56
|
+
[the api2 write-up of the same mechanism](../../api2/features/environment-variable-drives-underscore-branch.md)).
|
|
57
|
+
|
|
58
|
+
3. **Set `ENVIRONMENT` and add a local `Config/<name>.ini`.** `_Environment::$name` comes from the
|
|
59
|
+
`ENVIRONMENT` env var and selects `worker2/Config/<name>.ini` (the repo already carries one
|
|
60
|
+
`dev-<developer>-*.ini` per developer — clone the nearest). It needs at minimum:
|
|
61
|
+
- `[database]` pointed at `localhost`;
|
|
62
|
+
- a complete `[netsuite]` block. The key naming the signing certificate must be repointed from
|
|
63
|
+
the **server** path to your local copy under `worker2/Config/` — the shipped value is an
|
|
64
|
+
absolute server path and will not resolve on a laptop.
|
|
65
|
+
|
|
66
|
+
4. **Create a local `ClientLogs` schema — authentication writes to it.**
|
|
67
|
+
`_Component_Api_Netsuite::authenticate()` logs the OAuth token request to the client-logs
|
|
68
|
+
database, so NetSuite auth **fails on a machine that has no `ClientLogs`**, before any query
|
|
69
|
+
runs. Seed one from `dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL`.
|
|
70
|
+
This is the step that looks like a NetSuite credential problem and is not.
|
|
71
|
+
|
|
72
|
+
5. **Create a minimal local `Forecast` fixture.** For customer work only the `Customers` table is
|
|
73
|
+
needed — build just the tables the action touches rather than cloning the whole schema.
|
|
74
|
+
|
|
75
|
+
Then invoke the action **in-process** (call the static method directly). Do **not** route it
|
|
76
|
+
through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_url` points at the
|
|
77
|
+
**production** SQS queue.
|
|
78
|
+
|
|
79
|
+
## Gotchas / known issues
|
|
80
|
+
|
|
81
|
+
- **⚠ dev-sandbox's `Forecast` schema is ~6 months stale — do not test recent Forecast work there.**
|
|
82
|
+
Confirmed 2026-08-19: dev-sandbox has `Employees.departmentId` (a 2026-02 migration) but is
|
|
83
|
+
**missing** `Sales.amountDue` (2026-06-09), `Opportunities.clickupTaskId` (2026-07-20) and
|
|
84
|
+
`Customers.accountType` (2026-07-14) — all merged in `dbchanges2` `_main`. The `Forecast/` folder
|
|
85
|
+
appears not to have been applied to dev-sandbox since roughly February 2026. A dry run there dies
|
|
86
|
+
on the missing column, which reads like a code bug. Use a local fixture instead, and see
|
|
87
|
+
[repairing non-prod drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
|
|
88
|
+
- **⚠ SECURITY — live credentials are committed in this repo; they are locations to fix, not
|
|
89
|
+
settings to copy.** Do not paste any of these values anywhere, including a knowledge doc:
|
|
90
|
+
- `worker2/.ebextensions/git.json` carries a **live GitHub personal access token**, used so
|
|
91
|
+
Elastic Beanstalk can clone `_underscore` into the staging directory at deploy time. It is in
|
|
92
|
+
**git history**, so replacing the file is *not* sufficient — the token must be **rotated**.
|
|
93
|
+
- `worker2/Config/production.ini` carries live production database credentials.
|
|
94
|
+
- `test/@dave/CLAUDE.md` contains a production database password inline in an example.
|
|
95
|
+
Recommended direction (not yet actioned): AWS Secrets Manager or EB environment properties, with
|
|
96
|
+
the ini files reduced to non-secret settings. Raise this with a senior before a rotation, since
|
|
97
|
+
the deploy clone breaks the moment the token changes.
|
|
98
|
+
- **A local config that "works" proves nothing about the EB tier.** Branch and config both derive
|
|
99
|
+
from `ENVIRONMENT`, not from the EB environment's name — the api2 doc linked above documents a
|
|
100
|
+
live tier where the two disagree.
|
|
101
|
+
|
|
102
|
+
## Change history
|
|
103
|
+
|
|
104
|
+
- 2026-08-19 — Created while testing the customer account-type sync (TRUE-80206) with no usable
|
|
105
|
+
shared environment: recorded the five-step local boot (composer install with the `ext-zip` opt-out,
|
|
106
|
+
`set_include_path()` for `_underscore` vs. EB's `php_include_underscore.config`, `ENVIRONMENT` →
|
|
107
|
+
`Config/<name>.ini` with a local `[database]` and a repointed NetSuite certificate path, the
|
|
108
|
+
**`ClientLogs` schema that `_Component_Api_Netsuite::authenticate()` requires before any query**,
|
|
109
|
+
and a `Customers`-only `Forecast` fixture). Recorded that **dev-sandbox's `Forecast` schema is ~6
|
|
110
|
+
months behind `dbchanges2` `_main`** (missing `Sales.amountDue`, `Opportunities.clickupTaskId`,
|
|
111
|
+
`Customers.accountType`), which is why a local fixture is the fallback. Flagged committed live
|
|
112
|
+
credentials in `.ebextensions/git.json` (GitHub token — in git history, **rotation required**),
|
|
113
|
+
`Config/production.ini` and `test/@dave/CLAUDE.md`. (kyalamarthi)
|
|
114
|
+
|
|
115
|
+
## Related docs
|
|
116
|
+
|
|
117
|
+
- [Running 2.0 code from a bare CLI script](../../_underscore/features/cli-script-bootstrap.md) — the
|
|
118
|
+
framework-level bootstrap, transaction and `sql_mode` traps.
|
|
119
|
+
- [NetSuite Supporting-Record Backfill Worker](../features/netsuite-supporting-record-backfill-worker.md)
|
|
120
|
+
— the action this harness was built to test.
|
|
121
|
+
- [Repairing non-prod metadata drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
|
|
122
|
+
- [`ENVIRONMENT` decides the `_underscore` branch and Config file](../../api2/features/environment-variable-drives-underscore-branch.md)
|
|
123
|
+
— the api2 sibling of steps 2–3.
|
package/knowledge/INDEX.md
CHANGED
|
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
19
19
|
## 2.0 framework
|
|
20
20
|
|
|
21
21
|
- **_underscore** (_Underscore) _(framework core)_ — 60 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
|
-
- **worker2** (Worker) —
|
|
22
|
+
- **worker2** (Worker) — 53 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
23
|
- **api2** (API) — 24 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
24
|
- **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
25
25
|
- **toga2-supply** (TOGa Supply) — 7 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
package/package.json
CHANGED