toga-ai 1.0.1068 → 1.0.1069
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/architecture.md +10 -1
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +6 -1
- package/knowledge/2.0/apps/_underscore/features/customer-locations-files-notes.md +32 -8
- package/knowledge/2.0/apps/api2/features/cross-client-data-retrieval.md +6 -1
- package/package.json +1 -1
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
| [Contacts require a customerId — the Internal customer & every contact-creation path](features/contact-customer-required-internal-customer.md) | Every `Client_<Tenant>.Contacts` row must have a `customerId`; with no real customer, use the shared Internal customer (fixed uuid in every tenant). |
|
|
25
25
|
| [Contact related records — notes, files, contact types (slug), delete](features/contact-notes-files-types.md) | Shared Client-DB data model around a Contact (notes, files, typed contact types, delete); open before linking anything to a contact, keying on a contact type, o |
|
|
26
26
|
| [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the authoritative host↔client↔app↔env registry; a host is NOT derivable from env slug or api host — read the table. |
|
|
27
|
-
| [Customer
|
|
27
|
+
| [Customer data model — tiers, industries, customer since, locations, files, notes](features/customer-locations-files-notes.md) | Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer fiel |
|
|
28
28
|
| [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the |
|
|
29
29
|
| [Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop](features/effective-persona-resolution.md) | A user's EFFECTIVE persona set = UNION of three sources (`Users_Personas` + own-location + 5-hop sector `Locations_Personas`), owned by `_personaIds()`; open be |
|
|
30
30
|
| [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-17
|
|
10
10
|
owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/_underscore.php
|
|
@@ -436,7 +436,16 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
|
|
|
436
436
|
message/trace to the client if `display_errors` is on unconditionally. Confirm `display_errors` is
|
|
437
437
|
disabled in production and that the framework's error surface returns a sanitized envelope (not a raw
|
|
438
438
|
stack trace) to API consumers. Not yet done — tracked as a follow-up.
|
|
439
|
+
- **`class_exists('X')` throws, not false, for an unknown class.** It runs the `_Loader` autoloader,
|
|
440
|
+
which throws on a missing file → 500 EO-1. For optional PHP-extension classes (e.g. `Transliterator`,
|
|
441
|
+
intl is missing on local XAMPP) call `class_exists('\X', false)`. Live case:
|
|
442
|
+
`_Model_Client_CustomerLookup::deriveSlug()` (TRUE-82903).
|
|
443
|
+
- **A NOT NULL column's DB default never applies to a model INSERT.** `_Model` writes every declared
|
|
444
|
+
field explicitly, so a `FIELD_INTEGER` with no `FIELDOPT_DEFAULT` inserts NULL → MySQL 1048 on a
|
|
445
|
+
`NOT NULL DEFAULT 0` column. Declare `FIELDOPT_DEFAULT` on the model to match every NOT NULL column
|
|
446
|
+
default. Live cases: `CustomerTier` / `Industry.sortOrder` (TRUE-82903), `Cache Tables.safeRecordCount`.
|
|
439
447
|
|
|
440
448
|
## Change history
|
|
441
449
|
|
|
442
450
|
- 2026-10-07 — Corrected tenant DB-name resolution: resolve `Core.Clients` id columns into `Core.Databases`; `clientIdentifier` is not the suffix (Compass). (mhammontree)
|
|
451
|
+
- 2026-10-17 — Gotchas: `class_exists` autoload throws; model INSERT ignores DB column defaults (TRUE-82903). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-17
|
|
10
10
|
owners: [snaredla, jcardinal, bala, apeterson, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model.php
|
|
@@ -289,8 +289,13 @@ Keep the expression cheap, or expect callers to pay for it on every list.
|
|
|
289
289
|
same-parent child inserts then deadlock (MySQL 1213). This is exactly how `PurchaseOrder._total`
|
|
290
290
|
deadlocked the ASN path; see
|
|
291
291
|
[save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
|
|
292
|
+
- **Count calc fields are for listing columns only — not detail-tab counts.** A calc field skips the
|
|
293
|
+
child's row-ACL filters and cannot count across the Core vs Client cluster split. A detail tab's count
|
|
294
|
+
= the embedded tab table's own list total (`fields=uuid`, page size 1, `meta.totalRecordCount`). Full
|
|
295
|
+
rule: toga25-desk `docs/decisions/0153-*`, PLATFORM rule 37.
|
|
292
296
|
|
|
293
297
|
## Change history
|
|
298
|
+
- 2026-10-17 — Count calc fields: listing columns only; detail-tab counts use the list total (TRUE-82903). (jcardinal)
|
|
294
299
|
- 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
|
|
295
300
|
fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
|
|
296
301
|
client subclass overriding a *leaf* `_qty*` method was silently ignored unless it re-declared all
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Customer
|
|
2
|
+
title: Customer data model — tiers, industries, customer since, locations, files, notes
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: _underscore
|
|
5
5
|
project: _Underscore
|
|
@@ -10,39 +10,59 @@ updated: 2026-10-17
|
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/Customer.php
|
|
13
|
+
- _underscore/Model/Client/CustomerLookup.php
|
|
14
|
+
- _underscore/Model/Client/CustomerTier.php
|
|
15
|
+
- _underscore/Model/Client/Industry.php
|
|
13
16
|
- _underscore/Model/Client/Locations/Customer.php
|
|
14
17
|
- _underscore/Model/Client/Customers/File.php
|
|
15
18
|
- _underscore/Model/Client/CustomerNote.php
|
|
16
|
-
- dbchanges2/
|
|
19
|
+
- dbchanges2/Client/2026-10-17e - TRUE-82903 CustomerTiersIndustries.sql
|
|
20
|
+
- dbchanges2/Core/2026-10-17h - TRUE-82903 CustomerTiersIndustries.sql
|
|
17
21
|
- dbchanges2/Client/2026-10-17b - CustomerLocationsFilesNotes.sql
|
|
18
|
-
- dbchanges2/Core/2026-10-
|
|
22
|
+
- dbchanges2/Core/2026-10-17b - CustomerLocationsFilesNotesActivity.sql
|
|
19
23
|
related:
|
|
20
24
|
- ./record-hard-delete.md
|
|
21
25
|
- ./record-activity-timeline.md
|
|
22
26
|
- ./note-record-authorship.md
|
|
23
27
|
- ./tableview-joins.md
|
|
28
|
+
- ./calculated-sql-fields.md
|
|
24
29
|
- ./model-save-parent-cascade-stored-field-deadlock.md
|
|
25
30
|
- ./contact-customer-required-internal-customer.md
|
|
26
31
|
- ./acl-permission-chain.md
|
|
32
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
27
33
|
---
|
|
28
|
-
Shared Client-DB data model around a Customer (
|
|
34
|
+
Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer field, a customer lookup, or linking anything to a customer.
|
|
29
35
|
|
|
30
36
|
## Summary
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
Built for TOGa Desk (TRUE-82870, TRUE-82903), usable by any app. All tables live in every `Client_*` DB.
|
|
33
39
|
|
|
34
40
|
| Piece | Shape | Core ids |
|
|
35
41
|
|---|---|---|
|
|
36
42
|
| `Customers.primaryLocationId` | FK → Locations, standard field | RecordFields 2799 |
|
|
37
43
|
| `Customers._contactCount` | non-stored `FIELD_SQL` COUNT of Contacts by `customerId` | RecordFields 2800 |
|
|
44
|
+
| `Customers.customerTierId` | nullable FK → CustomerTiers | RecordFields 2826-2841 (with below) |
|
|
45
|
+
| `Customers.industryId` | nullable FK → Industries | |
|
|
46
|
+
| `Customers.dateCustomerSince` | `DATE` | |
|
|
47
|
+
| `Customers._siteCount` | non-stored `FIELD_SQL` COUNT of `Locations_Customers` rows | |
|
|
48
|
+
| `CustomerTiers` | lookup (name, slug, sortOrder, isActive) | Records 387 `customer-tiers` |
|
|
49
|
+
| `Industries` | lookup, same shape | Records 388 `industries` |
|
|
38
50
|
| `Locations_Customers` | many-to-many bridge, `UNIQUE (locationId, customerId)` | Records 382 `location-customers` |
|
|
39
51
|
| `Customers_Files` | copy of `Cases_Files`, `UNIQUE (customerId, fileId)` | Records 383 `customer-files` |
|
|
40
52
|
| `CustomerNotes` | copy of `CaseNotes` minus stage: write-once, internal | Records 384 `customer-notes` |
|
|
41
53
|
|
|
42
|
-
RecordFields 2801-2814 cover
|
|
54
|
+
RecordFields 2801-2814 cover 382-384. SQL: `Client/2026-10-17e` + `Core/2026-10-17h` (tiers/industries), `Client/2026-10-17b` + `Core/2026-10-17b` (locations/files/notes).
|
|
43
55
|
|
|
44
56
|
## How it works
|
|
45
57
|
|
|
58
|
+
- **Customer lookups (`CustomerTiers`, `Industries`)** extend abstract `_Model_Client_CustomerLookup`. Its PRE
|
|
59
|
+
POST/PUT/PATCH/DELETE api2 interceptors: trim `name`; derive `slug` from `name`; duplicate slug → readable 400;
|
|
60
|
+
`sortOrder` = MAX+1 when not sent; delete blocked while any customer points at the row. A new customer lookup =
|
|
61
|
+
subclass + `customerColumn()` + `deleteBlockedMessage()`. Subclasses declare `FIELDOPT_DEFAULT` for every
|
|
62
|
+
NOT NULL column (`sortOrder` 0, `isActive` true) — the model writes NULL otherwise.
|
|
63
|
+
- **Tier colours are data, not code:** Surface vocabulary `customer-tier` (kind TYPE) + client ThemeTokens
|
|
64
|
+
`color.tier.<slug>`.
|
|
65
|
+
- **"Customer since" = `dateCustomerSince`.** Never use `dtCreated` for it (that is the row's insert time).
|
|
46
66
|
- **Location of a customer:** use `primaryLocationId` (a real join). `_location` (city, state from the bill-to
|
|
47
67
|
Address) is kept for its existing readers — do not build new features on it.
|
|
48
68
|
- **Bridge, not `Locations.customerId`:** a location may belong to many customers (developer decision).
|
|
@@ -61,15 +81,19 @@ RecordFields 2801-2814 cover the three new records.
|
|
|
61
81
|
|
|
62
82
|
## Gotchas
|
|
63
83
|
|
|
84
|
+
- **`deriveSlug()` checks `Transliterator` with `class_exists('\X', false)`** — rule lives in
|
|
85
|
+
[_underscore architecture § Gotchas](../architecture.md#gotchas).
|
|
64
86
|
- **Never OUTER-join a many-to-many bridge into a shared TableView.** blox sends every join of a view on every
|
|
65
87
|
request (`buildJoinOptions`), so the general listing repeats each row once per bridge match. Make a
|
|
66
88
|
separate parent-scoped view instead (rule lives in [tableview-joins](./tableview-joins.md)).
|
|
67
|
-
- **`_contactCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
|
|
68
|
-
([details](./model-save-parent-cascade-stored-field-deadlock.md)).
|
|
89
|
+
- **`_contactCount` / `_siteCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
|
|
90
|
+
([details](./model-save-parent-cascade-stored-field-deadlock.md)). They are for listing columns; detail-tab
|
|
91
|
+
counts come from the tab table's own list total (see [calculated fields](./calculated-sql-fields.md)).
|
|
69
92
|
- **Assets link to a customer through Contacts** (`Units.contactId → Contacts.customerId`), not directly.
|
|
70
93
|
- **Copied RecordFields need `name`.** `CaseNotes` RecordFields 2743-2749 have NULL `name` (not fixed), so the
|
|
71
94
|
copy 2809-2814 came out NULL too; filled by `Core/2026-10-17d`. Set `name` on every new RecordField.
|
|
72
95
|
|
|
73
96
|
## Change history
|
|
97
|
+
- 2026-10-17 — Added CustomerTiers (387), Industries (388), `_Model_Client_CustomerLookup` interceptors, `customerTierId`, `industryId`, `dateCustomerSince`, `_siteCount` (TRUE-82903). (jcardinal)
|
|
74
98
|
- 2026-10-17 — Filled NULL `name` on RecordFields 2809-2814 (Core/2026-10-17d). (jcardinal)
|
|
75
99
|
- 2026-10-17 — Created: primaryLocationId, _contactCount, Locations_Customers, Customers_Files, CustomerNotes (TRUE-82870). (jcardinal)
|
|
@@ -116,7 +116,7 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
116
116
|
- **MySQL `GET_LOCK` names cap at 64 chars and ERROR rather than truncate.** The original lock name was 80 chars.
|
|
117
117
|
- **`_Query::fetchRows()` returns OBJECTS**, not arrays. CrossClient read them with array syntax throughout.
|
|
118
118
|
- **A V2 LIST nests records under the route key** (`data: {"salesOrders": [...]}`); `parseClientResponse` required `data` to be a flat array and discarded every successful sub-response as unusable.
|
|
119
|
-
- **`
|
|
119
|
+
- **`Tables.safeRecordCount` must be assigned in `buildCache`** — model INSERTs ignore DB column defaults (rule lives in [_underscore architecture § Gotchas](../../_underscore/architecture.md#gotchas)). Likewise a Core column (`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
|
|
120
120
|
- **Dangling foreign keys used to fail an entire listing.** `getFullModelData()` threw out of `_Model::initialize()` when a referenced row did not exist; one orphaned pointer killed the whole page. It now logs and expands the relationship to `null`. **This affects every REST listing/GET in api2, not just cross-client.**
|
|
121
121
|
- **`empty()`/`isset()` on a `_Model` field is unreliable** and fails in the "looks unauthenticated" direction — see [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
|
|
122
122
|
- **Switching the `Client` DB alias mid-request** needs the park/restore path — `_Database` keys live connection state by **alias**, not physical schema. See [Re-pointing a DB alias mid-request](../../_underscore/features/database-alias-repointing.md).
|
|
@@ -128,6 +128,10 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
128
128
|
requests must add `$api->cacheInvalidations['<recordId>:<clientId>'] = ['recordId' => …,
|
|
129
129
|
'clientId' => …]` for each record type it touched, or cross-client listings serve stale rows.
|
|
130
130
|
Example: `_Model_Client_CaseRecordRemoval` (TRUE-82889).
|
|
131
|
+
- **⚠ Fetch scope is either/or: `client=` scatter OR one tenant's own token — never both.** Sending
|
|
132
|
+
`client=` on another tenant's token makes `resolveScope()` fan out to THAT tenant's `Users_Clients`
|
|
133
|
+
(cross-tenant read). Seeded uuids also repeat across `Client_*` DBs, so multi-client UI rows must be
|
|
134
|
+
keyed by client uuid + record uuid. Full rule: toga25-desk ADR 0154, `GOTCHAS.md`.
|
|
131
135
|
- **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when `curl_multi_select() === -1`.
|
|
132
136
|
- **Known limitation — deep page-number paging is inherently O(page).** Serving page 4430 means materializing all 110,747 preceding rows into the cache. Adaptive chunking makes that cheaper, not cheap: cost still grows with depth, because a page **number** cannot be resolved without knowing everything before it. The durable answer is **exposing keyset cursors to the caller** so cost is independent of depth — the mechanism already exists (V2 supports `seekValues` + `seekUuid` correctly). Raised with the developer 2026-07-28 and **deliberately left unbuilt / not yet scoped**: a known limitation and candidate direction, not pending work.
|
|
133
137
|
|
|
@@ -141,4 +145,5 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
141
145
|
- [_Model magic-field access](../../_underscore/features/model-magic-field-access.md)
|
|
142
146
|
|
|
143
147
|
## Change history
|
|
148
|
+
- 2026-10-17 — Fetch scope is `client=` OR own token, never both; key multi-client rows by client + record uuid (TRUE-82903). (jcardinal)
|
|
144
149
|
- 2026-10-17 — Internal-request writes must add their own `cacheInvalidations` (TRUE-82889). (jcardinal)
|
package/package.json
CHANGED