toga-ai 1.0.487 → 1.0.489
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/.claude/settings.json +20 -0
- package/knowledge/2.0/apps/dbchanges2/architecture.md +75 -2
- package/knowledge/2.0/standards/framework-rules.md +24 -0
- package/knowledge/sessions/2026-07-31-true-80494-fulfill-ship-hotfix-mhammontree.md +108 -0
- package/package.json +1 -1
- package/scripts/hooks/dbchanges2-cluster-isolation.js +190 -0
- package/scripts/hooks/dbchanges2-record-ids.js +182 -0
package/.claude/settings.json
CHANGED
|
@@ -41,6 +41,26 @@
|
|
|
41
41
|
"timeout": 3000
|
|
42
42
|
}
|
|
43
43
|
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
47
|
+
"hooks": [
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "node \".claude/hooks/toga/dbchanges2-cluster-isolation.js\"",
|
|
51
|
+
"timeout": 3000
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "node \".claude/hooks/toga/dbchanges2-record-ids.js\"",
|
|
61
|
+
"timeout": 3000
|
|
62
|
+
}
|
|
63
|
+
]
|
|
44
64
|
}
|
|
45
65
|
],
|
|
46
66
|
"PostToolUse": [
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-31
|
|
10
10
|
owners: [jcardinal, mhammontree, bala, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- Core/
|
|
@@ -43,6 +43,9 @@ UI config) — **not just schema**; a schema-only blank produces non-functional
|
|
|
43
43
|
`Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any
|
|
44
44
|
`OtherDatabase.Table` reference is unrunnable in production even though it works locally
|
|
45
45
|
(see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook).
|
|
46
|
+
**`Core.Records` and `Core.RecordFields` are the only two tables platform-wide with
|
|
47
|
+
team-maintained `id`s** — ask the developer for the next value before inserting (never
|
|
48
|
+
`AUTO_INCREMENT`), and hardcode those `id`s wherever they are referenced, foreign keys included.
|
|
46
49
|
|
|
47
50
|
## File naming convention (the execution contract)
|
|
48
51
|
|
|
@@ -189,6 +192,61 @@ literals are stripped first, and table **aliases** (`rf.id`, `sibling.roleId`) a
|
|
|
189
192
|
> is **not** subject to it. If the hook ever misfires on legitimate SQL, `DBCHANGES2_ISOLATION_DISABLED=1`
|
|
190
193
|
> is an escape hatch **for false positives only** — never to land a cross-database query.
|
|
191
194
|
|
|
195
|
+
## `Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the platform
|
|
196
|
+
|
|
197
|
+
These **two tables, and only these two**, have **team-maintained primary keys**. Their `id`
|
|
198
|
+
values are treated as **stable, platform-wide constants**: the team tracks the next available
|
|
199
|
+
value **in the developer chat**, and every environment carries the same `id` for the same record
|
|
200
|
+
/ field. Nothing else on the platform may have a hardcoded `id`.
|
|
201
|
+
|
|
202
|
+
This is what makes the *Database isolation* rule above practical — it is the reason a
|
|
203
|
+
`Client_<Tenant>` migration never needs to read `Core` to find a `recordFieldId`.
|
|
204
|
+
|
|
205
|
+
### Inserting into them — ASK FIRST, never rely on AUTO_INCREMENT
|
|
206
|
+
|
|
207
|
+
**When a change inserts into `Core.Records` or `Core.RecordFields`, stop and ask the developer
|
|
208
|
+
for the next `id` value(s)**, then write them as explicit literals:
|
|
209
|
+
|
|
210
|
+
```sql
|
|
211
|
+
# Core/2026-07-31a - Add entitlements serviceAddressId field.sql
|
|
212
|
+
# id values 4187, 4188 assigned by the team (developer chat) — do NOT let AUTO_INCREMENT pick.
|
|
213
|
+
INSERT INTO RecordFields (id, uuid, recordId, `field`)
|
|
214
|
+
VALUES (4188, '<pre-generated v4 uuid>', 219, 'serviceAddressId');
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Never omit `id` and let MySQL's `AUTO_INCREMENT` assign it.** The column's `AUTO_INCREMENT`
|
|
218
|
+
definition still exists, but it is **not the source of truth** — allowing it to assign a value
|
|
219
|
+
lets environments drift apart, and every hardcoded reference to that `id` (in other databases,
|
|
220
|
+
in PHP, in other migrations) then points at the wrong row or nothing at all. There is no way to
|
|
221
|
+
detect this from inside the migration; it simply produces silently wrong ACL/field wiring.
|
|
222
|
+
|
|
223
|
+
Because the `id`s must be reserved by a human, **this cannot be guessed or derived** — asking is
|
|
224
|
+
mandatory, not a courtesy. Record the assigned values in a comment at the top of the file so the
|
|
225
|
+
next reader knows they were allocated, not invented.
|
|
226
|
+
|
|
227
|
+
### Referencing them — hardcode the `id`, including foreign keys
|
|
228
|
+
|
|
229
|
+
Anywhere a query references one of these rows — **and anywhere a foreign key points at them**
|
|
230
|
+
(`recordId`, `recordFieldId`, and their equivalents) — **hardcode the numeric `id`**. Do not look
|
|
231
|
+
it up:
|
|
232
|
+
|
|
233
|
+
```sql
|
|
234
|
+
# Client_Compass/2026-07-31a - Grant serviceAddress field write.sql
|
|
235
|
+
# recordFieldId 4188 = Core.RecordFields entitlements.serviceAddressId (team-assigned constant)
|
|
236
|
+
INSERT INTO AclFieldPermissions (uuid, recordFieldId, roleId, isWritable)
|
|
237
|
+
VALUES ('<pre-generated v4 uuid>', 4188, 3, 1);
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
This applies equally to SQL embedded in PHP in `worker2` / `api2` / `_underscore` — a hardcoded
|
|
241
|
+
`recordFieldId` is correct and preferred there too.
|
|
242
|
+
|
|
243
|
+
> **Always comment what the number is.** A bare `4188` is unreadable and unverifiable six months
|
|
244
|
+
> later. Name the record/field it refers to, as above.
|
|
245
|
+
|
|
246
|
+
Note that the `uuid` on these rows is **also** stable and environment-consistent, so
|
|
247
|
+
`WHERE uuid = '<literal>'` is a valid alternative when a readable key is preferred — but the
|
|
248
|
+
`id` is the team-maintained one, and it is what foreign keys store.
|
|
249
|
+
|
|
192
250
|
## `_modules` — reusable, opt-in change-sets
|
|
193
251
|
|
|
194
252
|
Some change-sets aren't applied to every client — only to clients that use a given **module**
|
|
@@ -295,6 +353,12 @@ its own header.)
|
|
|
295
353
|
foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the
|
|
296
354
|
work into one file per database folder. See *Database isolation* above — enforced by the
|
|
297
355
|
`dbchanges2-cluster-isolation` hook.
|
|
356
|
+
9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer for the next `id`.**
|
|
357
|
+
These are the **only two tables on the platform with team-maintained primary keys** (tracked in
|
|
358
|
+
the developer chat). Write the assigned `id` as an explicit literal — **never** let
|
|
359
|
+
`AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are
|
|
360
|
+
referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See
|
|
361
|
+
*`Core.Records` / `Core.RecordFields`* above.
|
|
298
362
|
|
|
299
363
|
## Bulk data loads — batch, and stage large sets in a temp table
|
|
300
364
|
|
|
@@ -447,7 +511,16 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
|
|
|
447
511
|
tables that `_Model_*` classes map to.
|
|
448
512
|
|
|
449
513
|
## Change history
|
|
450
|
-
- 2026-07-
|
|
514
|
+
- 2026-07-31 — **Added *`Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the
|
|
515
|
+
platform* + rule #9.** These two tables are the **only** ones platform-wide whose `id` is a
|
|
516
|
+
team-maintained constant: the next available value is tracked **in the developer chat**, so a
|
|
517
|
+
migration inserting into them must **ask the developer for the `id`** and write it as an explicit
|
|
518
|
+
literal — `AUTO_INCREMENT` must never assign it (silent per-environment drift breaks every
|
|
519
|
+
hardcoded reference). Conversely these `id`s **should** be hardcoded wherever referenced,
|
|
520
|
+
including foreign keys (`recordId`, `recordFieldId`) from client databases and in PHP query
|
|
521
|
+
strings. This is what makes *Database isolation* workable — no client migration needs to read
|
|
522
|
+
`Core` to resolve a `recordFieldId`. Enforced by the `dbchanges2-record-ids` hook. (jcardinal)
|
|
523
|
+
- 2026-07-31 — **Added *Database isolation — never query across databases* (HARD RULE) + rule #8.**
|
|
451
524
|
A `dbchanges2` `.sql` file may only reference tables in the one database its folder targets, and
|
|
452
525
|
must reference them unqualified; fan-out folders (`Client/`, `Logs_Client/`, `_modules/`) permit
|
|
453
526
|
no database qualifier at all. Rationale: in production `Core`, `Client_<Tenant>`,
|
|
@@ -137,6 +137,30 @@ Enforced mechanically by the `PreToolUse` hook
|
|
|
137
137
|
examples, and the approved rewrites: `2.0/apps/dbchanges2/architecture.md` → *Database
|
|
138
138
|
isolation*. **Applies to `dbchanges2` only — the 1.0 `dbchanges` repo is exempt.**
|
|
139
139
|
|
|
140
|
+
### `Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the platform
|
|
141
|
+
|
|
142
|
+
**These two tables, and only these two, have team-maintained primary keys.** Their `id` values are
|
|
143
|
+
**stable platform-wide constants**, identical in every environment, and the next available value is
|
|
144
|
+
tracked by the team **in the developer chat**. No other table on the platform may have a hardcoded
|
|
145
|
+
`id`.
|
|
146
|
+
|
|
147
|
+
**Inserting into them — ask first.** When a change inserts a row into `Core.Records` or
|
|
148
|
+
`Core.RecordFields`, **stop and ask the developer for the next `id`**, then write it as an explicit
|
|
149
|
+
literal. **Never let `AUTO_INCREMENT` assign it** — the column is still defined that way, but it is
|
|
150
|
+
not the source of truth, and a value it picks silently drifts between environments and invalidates
|
|
151
|
+
every hardcoded reference to that row. The `id` must be reserved by a human, so it cannot be
|
|
152
|
+
guessed or derived — asking is mandatory.
|
|
153
|
+
|
|
154
|
+
**Referencing them — hardcode the `id`.** Anywhere these rows are referenced, and anywhere a
|
|
155
|
+
**foreign key** points at them (`recordId`, `recordFieldId`, and equivalents), use the numeric
|
|
156
|
+
literal rather than a lookup — including in SQL embedded in PHP in `worker2` / `api2` /
|
|
157
|
+
`_underscore`. Always add a comment naming the record/field the number refers to; a bare `4188` is
|
|
158
|
+
unverifiable later.
|
|
159
|
+
|
|
160
|
+
This is also what makes the `dbchanges2` isolation rule above workable: a `Client_<Tenant>` change
|
|
161
|
+
never has to read `Core` to resolve a `recordFieldId`. Full detail and examples:
|
|
162
|
+
`2.0/apps/dbchanges2/architecture.md` → *`Core.Records` / `Core.RecordFields`*.
|
|
163
|
+
|
|
140
164
|
## Checking dependencies before touching shared code
|
|
141
165
|
|
|
142
166
|
`dependsOn` in `knowledge/registry.json` means a repo extends or depends on another repo's classes. Before modifying a class in a dependency repo (e.g. `_underscore` core):
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: true-80494-fulfill-ship-hotfix
|
|
4
|
+
title: TRUE-80494 Fulfill & Ship submit-blocker hotfix + Reference 1/2 carrier pass-through + beta metadata repairs
|
|
5
|
+
author: mhammontree
|
|
6
|
+
repos: [toga2-supply, _underscore, dbchanges2, api2]
|
|
7
|
+
framework: "2.0"
|
|
8
|
+
client: growrk
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-07-31
|
|
11
|
+
updated: 2026-07-31
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: true-80494-fulfill-ship-hotfix
|
|
15
|
+
**Date:** 2026-07-31
|
|
16
|
+
**Project/Repo:** toga2-supply (2.0) + _underscore / dbchanges2 / api2
|
|
17
|
+
**Task:** Root-cause why Fulfill & Ship stopped working in production for GroWrk after the TRUE-79191 deploy (2026-07-29), ship the TRUE-80494 hotfix, wire the Reference 1/2 fields through to the carrier so they print on shipping labels, and repair the beta/dev-sandbox metadata gaps blocking an end-to-end test.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
|
|
23
|
+
- **ROOT CAUSE of the prod outage — proven, not inferred.** Clicking Fulfill & Ship fired **no network request at all** (empty Network tab). Cause: `DUMMYUPDATESHIPMENTFIELDS.json:189-201` has a **display-only** section header (id 31, "Address") with `valueKey: ""` + `isRequired: true`. `validateFormOnSubmit.ts:37-44` does `getValues(field.valueKey)`; react-hook-form's `get` short-circuits on a falsy path so `getValues("")` is **always** `undefined` → `hasErrors = true` on **every** submit. Field 31 is **pre-existing** (confirmed via `git show b6cb3b426^`) and was harmless because the caller filtered blank keys. CodeRabbit batch 2 (`ba611989a`) broke it:
|
|
24
|
+
```diff
|
|
25
|
+
- let hasErrors = actualErrors.length > 0; // "" filtered out → harmless
|
|
26
|
+
+ let hasErrors = validationHasErrors || actualErrors.length > 0; // raw boolean → always true
|
|
27
|
+
```
|
|
28
|
+
The early `return` is silent: the error is keyed on `""` and that field renders through `BaseInput`'s `case "none"` branch (a bare `<p>`, no error slot), so nothing displays. **Blast radius: all clients, and all three actions** (Fulfill & Ship, Save, Save & Create New Shipment) — `onFormSubmit` gates all of them. Why local testing passed on 07-27: tracking number 9676 was saved **before** `ba611989a` existed.
|
|
29
|
+
- **The fix works — confirmed by the developer in prod-branch local.** Reverted only the caller in `EditShipmentForm.tsx:592` back to `actualErrors.length > 0` and dropped the now-unused `const validationHasErrors =`. Buttons work again and validation correctly blocks on an empty weight field. `npx tsc --noEmit` clean.
|
|
30
|
+
- **`dbchanges2` RecordFields id corrections — committed `5447f68`.** `2434 → 2480` (returnAddressId) and `2435 → 2481` (signatureType), matching what production actually holds.
|
|
31
|
+
- **`api2` `label_format = "PNG"` added to `Config/production.ini` — committed `851c227`.** Without it prod UPS defaults to ZPL while the return-label path expects a raster PNG for FPDF. `beta.ini` already had it, which is exactly why beta worked and prod didn't.
|
|
32
|
+
- **Reference 1/2 → carrier pass-through implemented** (uncommitted). Verified the transport mechanism first: these are **RecordScripts** (`Core.RecordScripts`), dispatched at `api2/Component/Api/V2/V2.php:4202` via `parse_str($_SERVER['QUERY_STRING'], $args)` then `$model::$method(...$args)` — PHP **named-argument unpacking**, so query-string keys bind to parameter names. `Core.RecordScripts` stores only `(recordId, method, route, phpMethod)` — no parameter signature — so **no migration is needed** to add params.
|
|
33
|
+
- **UPS label fix identified and implemented.** `Ups.php` was putting the reference into `Request.TransactionReference.CustomerContext`, which is UPS's request/response **correlation echo** — it never prints on a label. Replaced with real **package-level** `ReferenceNumber` entries via a new `buildPackageReferenceNumbers()` helper, wired into **both** payload builders (OAuth + legacy), following the existing `DCISType` conditional pattern. FedEx was already correct (`customerReferences`), just needed a second entry.
|
|
34
|
+
- **Carrier codes verified against docs** (web lookup): UPS `PO` = Purchase Order Number, `TN` = Transaction Reference Number, plus AJ/AT/BM/9V/ON/DP/3Q/IK/MK/MJ/PM/PC/RQ/RZ/SA/SE/ST/EI/TJ/SY. **UPS max 2 reference numbers, 35 chars each.** UPS prints the code's *meaning* next to the value. FedEx `customerReferenceType` accepts CUSTOMER_REFERENCE / P_O_NUMBER / INVOICE_NUMBER / DEPARTMENT_NUMBER / BILL_OF_LADING / RMA_ASSOCIATION / STORE_NUMBER / ELECTRONIC_PRODUCT_CODE / SHIPMENT_INTEGRITY / INTRACOUNTRY_REGULATORY_REFERENCE, **max 3 per package**, and rejects two entries of the same type.
|
|
35
|
+
- **All `php -l` clean; `tsc --noEmit` clean; JSON valid.**
|
|
36
|
+
- **BETA (= dev-sandbox cluster) METADATA REPAIRS — all applied and verified this session:**
|
|
37
|
+
1. **Empty unit dropdowns fixed.** `Client_Growrk` (dev-sandbox) had `lengthMeasureId` (2476) and `weightMeasureId` (2477) granted to **role 3 only**, and `measureType` (2478) with **zero** grant rows. Ungranted = the V2 engine omits the field from `GET /v2/measures` (200, no error), so the frontend's `measureType === "LENGTH"` split returned empty arrays. Final verified state: 2476 → roles 1,3; 2477 → roles 1,3; 2478 → role 1. Dropdowns now show `in`/`lb`.
|
|
38
|
+
2. **Save fixed (`EV-10`, "Column 'itemFulfillmentStageId' cannot be null").** `_Model_Client_ItemFulfillment::prePost()` defaults the stage to *shipped*, but it is **not called directly** — the V2 engine dispatches it from an `ApiPayloadInterceptors` registration, deriving the method as `strtolower('PRE') . ucfirst(strtolower('POST'))` → `prePost`. dev-sandbox `Core` was missing the `PRE`/`POST` row for recordId 28. Applied `Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql`; verified present and active. **Full-table drift check: that was the ONLY interceptor missing** — the other 8 match prod exactly. Stages themselves were already seeded (picked/packed/shipped).
|
|
39
|
+
3. **Tracking-number save fixed (`EV-8`, field `signatureType` does not exist).** dev-sandbox had neither the Core RecordField nor the column. Registered the field **omitting `id`** so auto_increment assigned it → landed at **2764**; added the `VARCHAR(255) utf8mb4` column `AFTER requiresSignature`; granted by mirroring `containsBattery` (field **962**). Verified: 2764 → roles 1 and 3.
|
|
40
|
+
- **Identified that "beta" reads dev-sandbox.** `toga2-supply/.env` → `VITE_API=https://api.beta.togahub.com/v2`; `api2/Config/beta.ini` `[database] hostname = dev.sandbox.database.togahub.com`. `Core` and `Client_Growrk` are on the **same cluster** in dev-sandbox, so `Core.`-qualified references in `Client/` migrations resolve there (in prod they are split across `prod-core`/`prod-client`).
|
|
41
|
+
|
|
42
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
43
|
+
|
|
44
|
+
- **The ACL/missing-migration theory for the PROD outage — FALSIFIED.** Every UoM grant, column, and seed row is present in prod `Client_Growrk` (2476/2477/2478 all role 1, `Measures.measureType` + `conversionFactorToBase`, Inch/Pound seeded, `TrackingNumbers` FK columns), and bridge records 317-322 each have their `AclLogicGroups` + `AclLogicGroupExpressions`. Do not re-investigate prod ACL for this bug.
|
|
45
|
+
- **Assuming the frontend/backend wasn't deployed — WRONG.** All four CodeRabbit commits (`e91f7029d`, `d1eba64af`, `ba611989a`, `e0942cb4f`) are on `toga2-supply` `_production` via PR #585, and `77fae34c` is on `_underscore` `_production` via PR #691. **The deployed code was precisely the problem.**
|
|
46
|
+
- **Shipping `dbchanges2` `3bc5e5b` as written — WRONG.** It asserts `signatureType = 2482`, but prod 2482 is already `entitlements.serviceAddressId` (TRUE-79533). Prod actually holds returnAddressId=2480 and signatureType=**2481**. Corrected to 2481 in `5447f68`.
|
|
47
|
+
- **Crediting `_underscore/Route.php` with the query-param → argument binding — WRONG.** `Route.php:46` builds `$routeVars` from `explode('/', urldecode($route))` — **path segments, not the query string**. The real dispatcher is the RecordScripts path in `api2/Component/Api/V2/V2.php:4202`.
|
|
48
|
+
- **Running `Client/2026-07-22c` in chunks / as a partial selection — SILENTLY HALF-APPLIES.** Its `@measuresSlugFieldId` / `@measureTypeFieldId` are `SET` *after* the first two INSERTs, so a chunked execution skips the measureType block, and `WHERE sibling.recordFieldId = NULL` matches nothing → **0 rows, no error**. This has now cost two debugging rounds (prod originally, dev-sandbox today). Fix by running the whole file in one batch, or use the inlined-subselect form.
|
|
49
|
+
- **Running the shipped `Core/2026-07-10a` / `Core/2026-07-16a` on dev-sandbox — WILL FAIL.** They hardcode ids 2480/2481, which in dev-sandbox belong to `vocabularies.id` / `vocabularies.uuid` (dev-sandbox `MAX(RecordFields.id)` was 2763; `isFulfillable` is 2615 there vs 2434 in prod). Ids genuinely diverge per environment. Omit `id` and let auto_increment assign instead.
|
|
50
|
+
- **Testing `_underscore` changes from local against beta — IMPOSSIBLE as configured.** The local Vite server supplies only the frontend; every API call goes to the **deployed** beta api2 + `_underscore`. Local `_underscore` edits are not in the request path. The developer pushed TRUE-80494 `_underscore` to beta to work around this.
|
|
51
|
+
- **Mirroring `needsReturnLabel` for grants** — its grants don't exist in every client (Client_True had none) → ungranted fields → EV-9. Mirror `shippingMethodId` (338) / `slug` (723) / `containsBattery` (962) / `shipToAddressId` (991) instead.
|
|
52
|
+
- **Re-running `Client/2026-07-22b`** — one-time backfill; a re-run coerces later NULL rows to Inch/Pound.
|
|
53
|
+
- **Initially mapping Reference 1 = PO / Reference 2 = SO — REVERSED.** The field-config placeholders are Reference 1 → `"e.g. NetSuite Sales Order #"` and Reference 2 → `"e.g. NetSuite Purchase Order #"`. Code now follows the UI.
|
|
54
|
+
- **`Measures.customerPurchaseOrder` as a PO source** — in GroWrk that column holds **people's names** ("Butch Dority", "Robin Rigg") on old rows only (id ≤ 7524) and is NULL on all recent orders. It is not a PO field. (It was the hardcoded source in `ItemFulfillment.php` for the carrier reference — now replaced.)
|
|
55
|
+
- **A `toga_query` containing the word "grant" in a string literal** is rejected by the MCP tool's keyword guard ("Forbidden keyword 'GRANT'"). Rename the label.
|
|
56
|
+
|
|
57
|
+
## Not tried yet (candidates for next session)
|
|
58
|
+
|
|
59
|
+
- **THE ACTUAL END-TO-END BETA TEST — interrupted mid-run.** The developer had a disposable SO ready and was retrying the tracking-number save right after the `signatureType` repair landed. Not yet done: fulfill, inspect the label, confirm NetSuite.
|
|
60
|
+
- **`returnAddressId` Core registration + grant on dev-sandbox.** SQL fully prepared and dependency-checked (mirror `shipToAddressId` field **991**, `childPolicy = 'CREATE'`, `type = NULL`, uuid `c3a1f2d4-5e6b-4c7a-9d8e-0f1a2b3c4d5e`, omit `id` → expect 2765). Only needed if testing return labels. The **column already exists** in dev-sandbox; only the Core row + grant are missing.
|
|
61
|
+
- **Hardening `Client/2026-07-22c`** (and `2026-07-16b`) to inline subselects instead of session variables so a partial execution fails loudly instead of silently.
|
|
62
|
+
- **Deciding whether the references belong on the RETURN label.** `ItemFulfillment.php:1468` (`generateReturnLabel`) still uses `$salesOrder->customerPurchaseOrder`. Deliberately left unchanged — it's the return leg to the warehouse, a product decision, not mechanical.
|
|
63
|
+
- **Confirming the Reference 1/2 semantic order with Eric/Skyler.** If Eric actually types the PO into Reference 1, the labels print the wrong descriptors — a two-line constant swap in `Ups.php` and `Fedex.php`.
|
|
64
|
+
- **Committing `toga2-supply` + `_underscore`** — both working trees are dirty on TRUE-80494.
|
|
65
|
+
- **Latent second silent-abort path:** `checkDimensions.ts:6` dereferences `formValues.dimensions.length` unguarded, and `handleFulfillAndShip` (`EditShipmentForm.tsx:770-772`) calls `onFormSubmit` without `await`/`.catch()` — a `TypeError` becomes an unhandled rejection with the same "nothing happens" symptom. Deserves its own ticket.
|
|
66
|
+
- **Dynamic weight symbol** on Pending Shipments cards is still a static `"lbs."`.
|
|
67
|
+
|
|
68
|
+
## Current file state
|
|
69
|
+
|
|
70
|
+
| File | Status | Notes |
|
|
71
|
+
|------|--------|-------|
|
|
72
|
+
| `toga2-supply` `src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx` | modified, **uncommitted** | Submit-guard revert (the outage fix) + `reference1`/`reference2` in `defaultValues` + passed into `fulfillShipment()` |
|
|
73
|
+
| `toga2-supply` `src/pages/EditShipment/api/UpdateShipmentApi.ts` | modified, **uncommitted** | `fulfillShipment()` takes both references, URL-encodes them onto the query string |
|
|
74
|
+
| `toga2-supply` `src/pages/EditShipment/types.ts` | modified, **uncommitted** | `reference1?`/`reference2?` on `ShipmentData` |
|
|
75
|
+
| `toga2-supply` `.../FIELDS/DUMMYUPDATESHIPMENTFIELDS.json` | modified, **uncommitted** | `"characterLimit": 35` on both reference fields (UPS cap) |
|
|
76
|
+
| `_underscore` `Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php` | modified, **uncommitted** | Added `$referenceString2` |
|
|
77
|
+
| `_underscore` `Component/Library/Carriers/Ups/Ups.php` | modified, **uncommitted** | New `buildPackageReferenceNumbers()`; package-level `ReferenceNumber` in **both** payload builders; code constants TN/PO |
|
|
78
|
+
| `_underscore` `Component/Library/Carriers/Fedex/Fedex.php` | modified, **uncommitted** | Two `customerReferences` entries; PRIMARY=CUSTOMER_REFERENCE, SECONDARY=P_O_NUMBER; `MAX_CUSTOMER_REFERENCES = 3` |
|
|
79
|
+
| `_underscore` `Model/Client/ItemFulfillment.php` | modified, **uncommitted** | Optional `$reference1`/`$reference2` on `fedexShipmentApi` + `upsShipmentApi`; the **two outbound** sites (763-764, 1251-1252) now use them. Return-label site (1468) intentionally untouched |
|
|
80
|
+
| `dbchanges2` `Core/2026-07-10a - ItemFulfillmentReturnAddressIdRecordField.sql` | **committed `5447f68`** | id 2434 → 2480 |
|
|
81
|
+
| `dbchanges2` `Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql` | **committed `5447f68`** | id 2435 → 2481 (NOT 2482 — that's `entitlements.serviceAddressId`) |
|
|
82
|
+
| `api2` `Config/production.ini` | **committed `851c227`** | `label_format = "PNG"` under `[ups]` |
|
|
83
|
+
| dev-sandbox `Core` | **DB changed** | `ApiPayloadInterceptors` PRE/POST for recordId 28; `RecordFields` signatureType @ **2764** |
|
|
84
|
+
| dev-sandbox `Client_Growrk` | **DB changed** | measure grants completed (2476/2477 role 1, 2478 role 1); `TrackingNumbers.signatureType` column; signatureType grants (roles 1,3) |
|
|
85
|
+
| `_underscore` beta deploy | pushed by developer | TRUE-80494 pushed to beta so the carrier changes are in the request path |
|
|
86
|
+
|
|
87
|
+
## Decisions made
|
|
88
|
+
|
|
89
|
+
- **Revert only the caller, not the validation helpers.** `validateFormOnSubmit` and `checkDimensions` keep returning booleans, because reverting them would destroy CodeRabbit **batch 3** (`e0942cb4f`), a genuinely correct `clearErrors` fix. Rejected: full revert of `ba611989a`.
|
|
90
|
+
- **Chose the revert over hardening the validator.** Developer's explicit call. Noted risk: it restores the first-submit-slips-through behaviour CodeRabbit flagged, and CodeRabbit will likely re-flag it. The alternative (skip fields with `!field.valueKey || inputType === "none"` inside the validator, keeping both fixes) is written into the plan file but **not applied**.
|
|
91
|
+
- **No `dbchanges2` work for the reference feature.** Skyler confirmed the values are user-entered, not stored and not prefilled from NetSuite — so no columns, no `Core.RecordFields`, no `AclFieldPermissions`. Rejected: the earlier full-stack persist-and-recall design.
|
|
92
|
+
- **UPS references at PACKAGE level, not shipment level.** UPS rejects `Shipment.ReferenceNumber` on US/PR→US/PR domestic shipments, which is most GroWrk volume.
|
|
93
|
+
- **Reference 1 = sales order → UPS `TN` / FedEx `CUSTOMER_REFERENCE`; Reference 2 = purchase order → UPS `PO` / FedEx `P_O_NUMBER`.** Driven by the form placeholders (the UI is what Eric follows), and kept consistent across carriers so a label reads the same either way. `TN` chosen over `ON` (Dealer Order Number) because UPS prints the code's meaning and `ON` would be misleading — UPS has no sales-order code.
|
|
94
|
+
- **Enforce the 35-char UPS cap on the form** via the existing `characterLimit` mechanism (visible RHF message) rather than truncating server-side. Silent truncation would print wrong data on a label; a visible form error is recoverable.
|
|
95
|
+
- **On dev-sandbox, omit `id` on `RecordFields` inserts** and let auto_increment assign, instead of adapting the hardcoded prod ids. Removes the whole class of collision.
|
|
96
|
+
- **Reuse prod uuids for the dev-sandbox metadata rows** so environments stay comparable (verified free before inserting).
|
|
97
|
+
- **Left `generateReturnLabel` unchanged** — return-leg references are a product decision, and the plan listed line 1468 before we knew it was the return path.
|
|
98
|
+
|
|
99
|
+
## Blockers
|
|
100
|
+
|
|
101
|
+
None technical. The developer was pulled to a more urgent task mid-test; the beta end-to-end run is incomplete. All known metadata gaps blocking the *save* path are fixed; `returnAddressId` remains unregistered on dev-sandbox and will surface as `EV-8` **only** if the return-label flow is exercised.
|
|
102
|
+
|
|
103
|
+
## Exact next step
|
|
104
|
+
|
|
105
|
+
> On beta (`growrk.togasupply:5173/edit-shipment?internalId=7221433`, or the disposable SO), retry the tracking-number save — it should now succeed with `signatureType` registered at 2764. Then fill both Reference fields, weight, and all three dimensions, click Fulfill & Ship, and **inspect the returned label image** for both reference values and which descriptor sits next to which ("Purchase Order" must be beside the Reference 2 value). A successful API response proves nothing here — UPS previously accepted the reference into `CustomerContext` and printed nothing. Be aware `beta.ini` and `production.ini` point at the **same NetSuite account (1095849)**, so the fulfillment writes to production NetSuite.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
_Saved by /session-save on 2026-07-31_
|
package/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* dbchanges2-cluster-isolation.js — DETERMINISTIC enforcement of the dbchanges2
|
|
6
|
+
* single-database rule.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS
|
|
9
|
+
* ---------------
|
|
10
|
+
* In production, the 2.0 databases are NOT on one server. Core, Client_<Tenant>,
|
|
11
|
+
* Archive_<Tenant>, Logs/Logs_<Tenant>, and Cache each live on an ENTIRELY
|
|
12
|
+
* SEPARATE cluster. A query that joins or sub-selects across those names is
|
|
13
|
+
* physically impossible in production — it cannot resolve the foreign schema.
|
|
14
|
+
*
|
|
15
|
+
* Locally and in non-prod every database happens to sit behind one endpoint, so a
|
|
16
|
+
* cross-database query runs fine there and the migration looks correct right up
|
|
17
|
+
* until it reaches production and dies. That silent local success is exactly why
|
|
18
|
+
* prose alone was not enough and this check is mechanical.
|
|
19
|
+
*
|
|
20
|
+
* THE RULE (hard, no exceptions)
|
|
21
|
+
* ------------------------------
|
|
22
|
+
* A .sql file in dbchanges2 may only reference tables in the ONE database its
|
|
23
|
+
* folder targets. References to that database are UNQUALIFIED. Any qualified
|
|
24
|
+
* `OtherDatabase.Table` reference is a violation — including in fan-out folders
|
|
25
|
+
* (Client/, Logs_Client/, _modules/), where the database name varies per tenant
|
|
26
|
+
* and therefore NOTHING may be qualified.
|
|
27
|
+
*
|
|
28
|
+
* Instead of reading a foreign database to resolve an id, use a hardcoded v4 UUID
|
|
29
|
+
* literal, a slug/natural key that exists inside the target database, or split the
|
|
30
|
+
* work into one file per database folder.
|
|
31
|
+
*
|
|
32
|
+
* SCOPE
|
|
33
|
+
* -----
|
|
34
|
+
* Only .sql files under a dbchanges2 checkout. The 1.0 `dbchanges` repo is
|
|
35
|
+
* explicitly NOT affected (its path is excluded). Every other write passes
|
|
36
|
+
* through untouched.
|
|
37
|
+
*
|
|
38
|
+
* Fail-open: any internal/parse error allows the write. A crashing guard must
|
|
39
|
+
* never brick editing.
|
|
40
|
+
*
|
|
41
|
+
* Escape hatch (false positives ONLY, not for writing cross-database SQL):
|
|
42
|
+
* DBCHANGES2_ISOLATION_DISABLED=1
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
|
|
47
|
+
/* ---------- input: read stdin JSON (CC standard), fall back to env ---------- */
|
|
48
|
+
function readPayload() {
|
|
49
|
+
let raw = '';
|
|
50
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
|
|
51
|
+
let data = {};
|
|
52
|
+
if (raw && raw.trim()) {
|
|
53
|
+
try { data = JSON.parse(raw); } catch (e) { data = {}; }
|
|
54
|
+
}
|
|
55
|
+
if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
|
|
56
|
+
if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
|
|
57
|
+
try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) {}
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Known 2.0 database families. We match against this bounded set rather than a
|
|
63
|
+
* generic `x.y` pattern so ordinary table aliases (`rf.id`, `sibling.roleId`)
|
|
64
|
+
* never trip the check. */
|
|
65
|
+
const DB_FAMILIES = ['Core', 'Client', 'Logs', 'Archive', 'Cache', 'Team'];
|
|
66
|
+
|
|
67
|
+
/* Strip comments and string literals so a database name mentioned in prose or in
|
|
68
|
+
* a quoted value is not reported as a reference. Newlines are preserved so the
|
|
69
|
+
* reported line numbers still match the original file. */
|
|
70
|
+
function stripNonCode(sql) {
|
|
71
|
+
const blanked = (m) => m.replace(/[^\n]/g, ' ');
|
|
72
|
+
return sql
|
|
73
|
+
.replace(/\/\*[\s\S]*?\*\//g, blanked) // /* block comments */
|
|
74
|
+
.replace(/(^|[^\w$])#[^\n]*/g, (m) => m[0] === '#' ? blanked(m) : m[0] + blanked(m.slice(1)))
|
|
75
|
+
.replace(/--[ \t][^\n]*/g, blanked) // -- line comments (MySQL needs the space)
|
|
76
|
+
.replace(/'(?:\\.|''|[^'\\])*'/g, blanked) // 'string literals'
|
|
77
|
+
.replace(/"(?:\\.|""|[^"\\])*"/g, blanked); // "string literals"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Resolve which database a file targets from its folder, and whether that folder
|
|
81
|
+
* fans out across tenants (in which case no qualifier at all is permitted). */
|
|
82
|
+
function resolveTarget(filePath) {
|
|
83
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
84
|
+
const m = norm.match(/\/dbchanges2\/(.+)$/i);
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
const rel = m[1];
|
|
87
|
+
const seg = rel.split('/')[0];
|
|
88
|
+
if (!seg || seg === rel) return null; // file sits at repo root, no DB folder
|
|
89
|
+
if (/^_modules$/i.test(seg)) {
|
|
90
|
+
return { folder: seg, db: null, fanout: true, why: 'a module change-set applied inside each opted-in client database' };
|
|
91
|
+
}
|
|
92
|
+
if (/^Client$/i.test(seg)) {
|
|
93
|
+
return { folder: seg, db: null, fanout: true, why: 'fan-out to every Client_<Tenant> database' };
|
|
94
|
+
}
|
|
95
|
+
if (/^Logs_Client$/i.test(seg)) {
|
|
96
|
+
return { folder: seg, db: null, fanout: true, why: 'fan-out to every Logs_<Tenant> database' };
|
|
97
|
+
}
|
|
98
|
+
return { folder: seg, db: seg, fanout: false, why: 'the ' + seg + ' database only' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Find every qualified reference to a known database family. */
|
|
102
|
+
function findQualifiedRefs(sql) {
|
|
103
|
+
const code = stripNonCode(sql);
|
|
104
|
+
const names = DB_FAMILIES.join('|');
|
|
105
|
+
// `Core`.`Records` / Core.Records / Client_Aig.SalesOrders / USE Core
|
|
106
|
+
const re = new RegExp(
|
|
107
|
+
'`?\\b((?:' + names + ')(?:_[A-Za-z0-9]+)?)`?\\s*\\.\\s*`?[A-Za-z_][A-Za-z0-9_]*`?',
|
|
108
|
+
'g'
|
|
109
|
+
);
|
|
110
|
+
const useRe = new RegExp('\\bUSE\\s+`?((?:' + names + ')(?:_[A-Za-z0-9]+)?)`?', 'gi');
|
|
111
|
+
const found = [];
|
|
112
|
+
const push = (index, db, text) => {
|
|
113
|
+
const line = code.slice(0, index).split('\n').length;
|
|
114
|
+
found.push({ line, db, text: text.trim() });
|
|
115
|
+
};
|
|
116
|
+
let hit;
|
|
117
|
+
while ((hit = re.exec(code)) !== null) push(hit.index, hit[1], hit[0]);
|
|
118
|
+
while ((hit = useRe.exec(code)) !== null) push(hit.index, hit[1], hit[0]);
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
if (process.env.DBCHANGES2_ISOLATION_DISABLED === '1') return;
|
|
124
|
+
|
|
125
|
+
const data = readPayload();
|
|
126
|
+
const tool = data.tool_name || '';
|
|
127
|
+
if (!/^(Write|Edit|MultiEdit)$/.test(tool)) return;
|
|
128
|
+
|
|
129
|
+
const input = data.tool_input || {};
|
|
130
|
+
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
|
|
131
|
+
if (!/\.sql$/i.test(filePath)) return;
|
|
132
|
+
|
|
133
|
+
const target = resolveTarget(filePath);
|
|
134
|
+
if (!target) return; // not a dbchanges2 .sql file (1.0 dbchanges excluded)
|
|
135
|
+
if (/\/HISTORIC\//i.test(filePath.replace(/\\/g, '/'))) return; // archive, never executed
|
|
136
|
+
|
|
137
|
+
// Gather the SQL this call would introduce, across all three tool shapes.
|
|
138
|
+
let sql = '';
|
|
139
|
+
if (typeof input.content === 'string') sql += input.content + '\n';
|
|
140
|
+
if (typeof input.new_string === 'string') sql += input.new_string + '\n';
|
|
141
|
+
if (Array.isArray(input.edits)) {
|
|
142
|
+
for (const e of input.edits) {
|
|
143
|
+
if (e && typeof e.new_string === 'string') sql += e.new_string + '\n';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!sql.trim()) return;
|
|
147
|
+
|
|
148
|
+
const violations = findQualifiedRefs(sql).filter((v) => {
|
|
149
|
+
if (target.fanout) return true; // no qualifier is ever valid here
|
|
150
|
+
return v.db.toLowerCase() !== target.db.toLowerCase();
|
|
151
|
+
});
|
|
152
|
+
if (!violations.length) return;
|
|
153
|
+
|
|
154
|
+
const lines = [];
|
|
155
|
+
lines.push('BLOCKED by dbchanges2 database isolation — this SQL reads across a database boundary.');
|
|
156
|
+
lines.push('');
|
|
157
|
+
lines.push(' File: ' + filePath);
|
|
158
|
+
lines.push(' Folder: ' + target.folder + '/ → targets ' + target.why);
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(' Foreign reference' + (violations.length === 1 ? '' : 's') + ':');
|
|
161
|
+
for (const v of violations.slice(0, 20)) {
|
|
162
|
+
lines.push(' line ' + v.line + ': ' + v.text + ' ← ' + v.db + ' is a different database');
|
|
163
|
+
}
|
|
164
|
+
if (violations.length > 20) lines.push(' … and ' + (violations.length - 20) + ' more');
|
|
165
|
+
lines.push('');
|
|
166
|
+
lines.push('In PRODUCTION, Core / Client_<Tenant> / Archive_<Tenant> / Logs / Cache are on');
|
|
167
|
+
lines.push('ENTIRELY SEPARATE CLUSTERS. This query cannot resolve there — it only appears to');
|
|
168
|
+
lines.push('work locally and in non-prod, where every database shares one endpoint.');
|
|
169
|
+
lines.push('');
|
|
170
|
+
if (target.fanout) {
|
|
171
|
+
lines.push('This folder fans out across tenants, so the database name is not fixed:');
|
|
172
|
+
lines.push('reference tables UNQUALIFIED only — no database prefix at all.');
|
|
173
|
+
} else {
|
|
174
|
+
lines.push('Reference only tables in ' + target.db + ', and reference them UNQUALIFIED.');
|
|
175
|
+
}
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push('Rewrite it without the cross-database read:');
|
|
178
|
+
lines.push(' • hardcode a pre-generated v4 UUID literal instead of SELECTing an id;');
|
|
179
|
+
lines.push(' • resolve rows by a slug / natural key that exists in the target database;');
|
|
180
|
+
lines.push(' • split the work into one file per database folder.');
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push('See knowledge/2.0/apps/dbchanges2/architecture.md → "Database isolation".');
|
|
183
|
+
lines.push('(The 1.0 dbchanges repo is not subject to this rule.)');
|
|
184
|
+
// A PreToolUse deny (exit 2) surfaces its reason from STDERR — writing to stdout
|
|
185
|
+
// blocks the call but shows the agent nothing, so the explanation is lost.
|
|
186
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try { main(); } catch (e) { /* fail-open */ }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* dbchanges2-record-ids.js — DETERMINISTIC enforcement of the team-maintained
|
|
6
|
+
* primary keys on Core.Records and Core.RecordFields.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS
|
|
9
|
+
* ---------------
|
|
10
|
+
* These two tables are the ONLY tables on the 2.0 platform whose `id` is a
|
|
11
|
+
* team-maintained constant. The next available value is tracked by the team in the
|
|
12
|
+
* developer chat, and the same row carries the same `id` in every environment —
|
|
13
|
+
* which is what lets every other database (and PHP query) hardcode `recordId` /
|
|
14
|
+
* `recordFieldId` instead of joining out to Core.
|
|
15
|
+
*
|
|
16
|
+
* If a migration omits `id` and lets MySQL's AUTO_INCREMENT assign one, the value
|
|
17
|
+
* differs per environment. Nothing fails at run time: the insert succeeds, and
|
|
18
|
+
* every hardcoded reference to that row silently points at the wrong record or at
|
|
19
|
+
* nothing. That is unrecoverable without a manual audit, so it has to be caught
|
|
20
|
+
* before the file is written.
|
|
21
|
+
*
|
|
22
|
+
* THE RULE
|
|
23
|
+
* --------
|
|
24
|
+
* An INSERT/REPLACE into Records or RecordFields must assign `id` explicitly.
|
|
25
|
+
* The developer must be ASKED for the value first — it cannot be derived, so this
|
|
26
|
+
* hook deliberately offers no way to satisfy it automatically.
|
|
27
|
+
*
|
|
28
|
+
* SCOPE
|
|
29
|
+
* -----
|
|
30
|
+
* .sql files under dbchanges2 whose folder targets Core (or which reference the
|
|
31
|
+
* tables Core-qualified). The 1.0 `dbchanges` repo is NOT affected. Sibling tables
|
|
32
|
+
* whose names merely END in RecordFields (CustomRecordFields, SectionRecordFields)
|
|
33
|
+
* are NOT covered — only the two exact tables.
|
|
34
|
+
*
|
|
35
|
+
* Fail-open on any internal error.
|
|
36
|
+
*
|
|
37
|
+
* Escape hatch (false positives ONLY): DBCHANGES2_RECORD_IDS_DISABLED=1
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
|
|
42
|
+
function readPayload() {
|
|
43
|
+
let raw = '';
|
|
44
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
|
|
45
|
+
let data = {};
|
|
46
|
+
if (raw && raw.trim()) {
|
|
47
|
+
try { data = JSON.parse(raw); } catch (e) { data = {}; }
|
|
48
|
+
}
|
|
49
|
+
if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
|
|
50
|
+
if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
|
|
51
|
+
try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) {}
|
|
52
|
+
}
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Blank out comments and string literals, preserving newlines so reported line
|
|
57
|
+
* numbers still line up with the original file. */
|
|
58
|
+
function stripNonCode(sql) {
|
|
59
|
+
const blanked = (m) => m.replace(/[^\n]/g, ' ');
|
|
60
|
+
return sql
|
|
61
|
+
.replace(/\/\*[\s\S]*?\*\//g, blanked)
|
|
62
|
+
.replace(/(^|[^\w$])#[^\n]*/g, (m) => (m[0] === '#' ? blanked(m) : m[0] + blanked(m.slice(1))))
|
|
63
|
+
.replace(/--[ \t][^\n]*/g, blanked)
|
|
64
|
+
.replace(/'(?:\\.|''|[^'\\])*'/g, blanked)
|
|
65
|
+
.replace(/"(?:\\.|""|[^"\\])*"/g, blanked);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Which database does this file's folder target? */
|
|
69
|
+
function targetDb(filePath) {
|
|
70
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
71
|
+
const m = norm.match(/\/dbchanges2\/(.+)$/i);
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
const seg = m[1].split('/')[0];
|
|
74
|
+
if (!seg || seg === m[1]) return null;
|
|
75
|
+
return seg;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Find INSERT/REPLACE statements targeting the two protected tables and report any
|
|
79
|
+
* that do not assign `id`. */
|
|
80
|
+
function findUnassignedIds(sql) {
|
|
81
|
+
const code = stripNonCode(sql);
|
|
82
|
+
// (?<![A-Za-z0-9_`]) prevents matching CustomRecordFields / SectionRecordFields.
|
|
83
|
+
const re = /\b(?:INSERT(?:\s+IGNORE)?|REPLACE)\s+INTO\s+(?:`?Core`?\s*\.\s*)?`?(?<![A-Za-z0-9_])(Records|RecordFields)`?/gi;
|
|
84
|
+
const found = [];
|
|
85
|
+
let hit;
|
|
86
|
+
while ((hit = re.exec(code)) !== null) {
|
|
87
|
+
const table = hit[1];
|
|
88
|
+
const after = code.slice(hit.index + hit[0].length, hit.index + hit[0].length + 2000);
|
|
89
|
+
let assignsId = false;
|
|
90
|
+
|
|
91
|
+
// Form A: INSERT INTO t (col, col, ...) VALUES/SELECT ...
|
|
92
|
+
const colList = after.match(/^\s*\(([\s\S]*?)\)/);
|
|
93
|
+
if (colList) {
|
|
94
|
+
assignsId = colList[1]
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((c) => c.trim().replace(/`/g, '').toLowerCase())
|
|
97
|
+
.includes('id');
|
|
98
|
+
} else {
|
|
99
|
+
// Form B: INSERT INTO t SET id = 4188, ...
|
|
100
|
+
const setClause = after.match(/^\s*SET\b([\s\S]*?)(?:;|$)/i);
|
|
101
|
+
if (setClause) assignsId = /(^|[\s,`])id\s*=/i.test(setClause[1]);
|
|
102
|
+
// Form C: INSERT INTO t SELECT ... — no column list, cannot verify; treat as
|
|
103
|
+
// unassigned so it gets a human look.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!assignsId) {
|
|
107
|
+
found.push({
|
|
108
|
+
line: code.slice(0, hit.index).split('\n').length,
|
|
109
|
+
table,
|
|
110
|
+
text: hit[0].replace(/\s+/g, ' ').trim(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function main() {
|
|
118
|
+
if (process.env.DBCHANGES2_RECORD_IDS_DISABLED === '1') return;
|
|
119
|
+
|
|
120
|
+
const data = readPayload();
|
|
121
|
+
if (!/^(Write|Edit|MultiEdit)$/.test(data.tool_name || '')) return;
|
|
122
|
+
|
|
123
|
+
const input = data.tool_input || {};
|
|
124
|
+
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
|
|
125
|
+
if (!/\.sql$/i.test(filePath)) return;
|
|
126
|
+
|
|
127
|
+
const db = targetDb(filePath);
|
|
128
|
+
if (!db) return; // not a dbchanges2 .sql file
|
|
129
|
+
if (/\/HISTORIC\//i.test(filePath.replace(/\\/g, '/'))) return; // archive, never executed
|
|
130
|
+
|
|
131
|
+
let sql = '';
|
|
132
|
+
if (typeof input.content === 'string') sql += input.content + '\n';
|
|
133
|
+
if (typeof input.new_string === 'string') sql += input.new_string + '\n';
|
|
134
|
+
if (Array.isArray(input.edits)) {
|
|
135
|
+
for (const e of input.edits) {
|
|
136
|
+
if (e && typeof e.new_string === 'string') sql += e.new_string + '\n';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!sql.trim()) return;
|
|
140
|
+
|
|
141
|
+
// Records/RecordFields live in Core. Only inspect files that target Core, or that
|
|
142
|
+
// name the tables Core-qualified (the isolation hook handles the latter's legality).
|
|
143
|
+
if (!/^Core$/i.test(db) && !/`?Core`?\s*\.\s*`?(Records|RecordFields)\b/i.test(stripNonCode(sql))) return;
|
|
144
|
+
|
|
145
|
+
const violations = findUnassignedIds(sql);
|
|
146
|
+
if (!violations.length) return;
|
|
147
|
+
|
|
148
|
+
const out = [];
|
|
149
|
+
out.push('BLOCKED: insert into ' + violations.map((v) => v.table).filter((t, i, a) => a.indexOf(t) === i).join(' / ') +
|
|
150
|
+
' without an explicit `id`.');
|
|
151
|
+
out.push('');
|
|
152
|
+
out.push(' File: ' + filePath);
|
|
153
|
+
for (const v of violations.slice(0, 20)) {
|
|
154
|
+
out.push(' line ' + v.line + ': ' + v.text + ' ← no `id` assigned');
|
|
155
|
+
}
|
|
156
|
+
if (violations.length > 20) out.push(' … and ' + (violations.length - 20) + ' more');
|
|
157
|
+
out.push('');
|
|
158
|
+
out.push('Core.Records and Core.RecordFields are the ONLY two tables on the platform with');
|
|
159
|
+
out.push('TEAM-MAINTAINED primary keys. The next available id is tracked by the team in the');
|
|
160
|
+
out.push('developer chat, and the same row must carry the same id in EVERY environment —');
|
|
161
|
+
out.push('that is what lets other databases hardcode recordId / recordFieldId instead of');
|
|
162
|
+
out.push('joining out to Core.');
|
|
163
|
+
out.push('');
|
|
164
|
+
out.push('Letting AUTO_INCREMENT assign the id fails silently: the insert succeeds, the id');
|
|
165
|
+
out.push('differs per environment, and every hardcoded reference to that row then points at');
|
|
166
|
+
out.push('the wrong record or at nothing.');
|
|
167
|
+
out.push('');
|
|
168
|
+
out.push('REQUIRED: ask the developer what the next id value(s) are, then write them as');
|
|
169
|
+
out.push('explicit literals and note in a comment that the team assigned them:');
|
|
170
|
+
out.push('');
|
|
171
|
+
out.push(' # id 4188 assigned by the team (developer chat)');
|
|
172
|
+
out.push(' INSERT INTO RecordFields (id, uuid, recordId, `field`)');
|
|
173
|
+
out.push(" VALUES (4188, '<v4 uuid literal>', 219, 'serviceAddressId');");
|
|
174
|
+
out.push('');
|
|
175
|
+
out.push('This value cannot be derived or guessed — you must ask.');
|
|
176
|
+
out.push('See knowledge/2.0/apps/dbchanges2/architecture.md → "Core.Records / Core.RecordFields".');
|
|
177
|
+
// exit 2 surfaces the reason from STDERR; stdout would block silently.
|
|
178
|
+
process.stderr.write(out.join('\n') + '\n');
|
|
179
|
+
process.exit(2);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try { main(); } catch (e) { /* fail-open */ }
|