@proveanything/smartlinks 1.11.1 → 1.11.4

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.
@@ -1,334 +1,305 @@
1
- # SmartLinks Records-Based Admin Pattern
2
-
3
- > Canonical guide for building admin UIs in microapps that store **per-product**, **per-facet**, **per-variant** or **per-batch** data.
4
- >
5
- > Audience: microapp developers (nutrition, allergy, ingredients, cooking-guide, warranty, provenance, …).
6
- >
7
- > Status: **standard** new admin apps in this category MUST follow this contract. Existing apps SHOULD migrate.
8
-
9
- ---
10
-
11
- ## 1. Why a shared pattern?
12
-
13
- Many SmartLinks microapps store **structured data that varies along one or more product axes**:
14
-
15
- | App | Varies by |
16
- |------------------|------------------------------------------------|
17
- | Nutrition | product, facet (bread type, region), batch |
18
- | Allergy | product, facet (recipe family) |
19
- | Ingredients | product, variant (size), batch (production run)|
20
- | Cooking guide | product, facet (cut of meat) |
21
- | Warranty | product, variant, batch |
22
- | Provenance | batch |
23
-
24
- Without a shared pattern, every app reinvents:
25
-
26
- - the left-rail "browse + select" list
27
- - the per-axis precedence rules
28
- - the inheritance/override UI
29
- - the "no data yet" empty state
30
- - CSV import/export
31
- - bulk operations
32
-
33
- The result is drift: each app feels different and admins have to re-learn the model. This guide locks the model down at the SDK level so the matching UI primitives in `@proveanything/smartlinks-utils-ui` (see the [companion guide](ui-utils.md)) can stay simple.
34
-
35
- ---
36
-
37
- ## 2. Storage model: `app.records`
38
-
39
- **Do not** stuff per-axis data into `appConfiguration.products[productId]`. Use `app.records` (recordType-keyed) so that data is queryable, paginatable and survives schema changes.
40
-
41
- ```ts
42
- import { app } from '@proveanything/smartlinks';
43
-
44
- await app.records.create(collectionId, appId, {
45
- recordType: 'nutrition', // app-defined, stable
46
- ref: 'product:prod_abc', // see §3
47
- data: { /* domain payload */ },
48
- }, /* admin= */ true);
49
- ```
50
-
51
- Each record has:
52
-
53
- | Field | Purpose |
54
- |--------------|-------------------------------------------------------------|
55
- | `recordType` | Namespaces records inside the app. One app may have several (`nutrition`, `cooking_steps`). |
56
- | `ref` | Encodes the **scope** of the record. See §3. |
57
- | `data` | The domain payload. Free-form per app. |
58
- | `meta` | Reserved for system fields (timestamps, author). |
59
-
60
- > **Rule:** `(appId, recordType, ref)` is unique. Treat it as the natural key.
61
-
62
- ---
63
-
64
- ## 3. The `ref` convention (REQUIRED)
65
-
66
- `ref` is a colon-delimited string that encodes which scope a record applies to. Standardising it is what makes the shared admin shell possible.
67
-
68
- ```
69
- product:<productId>
70
- variant:<productId>:<variantId>
71
- batch:<productId>:<batchId>
72
- proof:<proofId>
73
- facet:<facetKey>:<valueKey>
74
- '' (universal / collection-wide fallback)
75
- ```
76
-
77
- Notes:
78
-
79
- - Variants and batches are **always nested under a product** in the SDK, so their refs include `productId`.
80
- - `facet:` refs are **collection-wide** — facets cross products by design.
81
- - `''` (empty ref) is the **universal** record — one per `(app, recordType)` with no scope restrictions; the collection-wide fallback.
82
- - Refs are opaque to the SDK. Apps parse them. A helper module (`@proveanything/smartlinks-utils-ui/records-admin`) exports `parseRef`/`buildRef` so all apps agree on syntax. See Appendix A for the full implementation.
83
-
84
- ### Adding scopes later
85
-
86
- If a new axis appears (e.g. `region:eu`), pick a new prefix and document it. Never reuse a prefix with different semantics.
87
-
88
- ### Writing records
89
-
90
- When creating or upserting a record, send a structured **`RecordScope`** — the server derives `ref` from it:
91
-
92
- ```ts
93
- await app.records.upsert(collectionId, appId, {
94
- recordType: 'nutrition',
95
- scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server → ref: 'product:prod_abc/variant:var_500ml'
96
- data: { calories: 260 },
97
- });
98
- ```
99
-
100
- - `ref` is for **display, URL routing, and resolution output only** — never construct one to use as an upsert key.
101
- - `customId` / `sourceSystem` are for external references (filterable via `list()`) but are **not unique** — the same external ID can exist across `recordType` values. Do not upsert on `customId` either.
102
-
103
- ---
104
-
105
- ## 4. Resolution order (REQUIRED)
106
-
107
- When the **public** side of an app needs "the data that applies to this proof / product / context", it walks the chain from most-specific to least-specific and returns the first match:
108
-
109
- ```
110
- proof batch variant product facet(*) universal
111
- ```
112
-
113
- `facet(*)` means: walk every facet attached to the product in a deterministic order (alphabetical by `facetKey`, then `valueKey`) and use the first matching facet record.
114
-
115
- > **Rule:** Resolution is **first-match-wins, not merge**. If you need field-level merging, build it on top with explicit `inheritsFrom` markers in the payload — but the default for shared infra is whole-record replacement, because it is far easier to reason about.
116
-
117
- The canonical resolver lives in `@proveanything/smartlinks-utils-ui/records-admin` so every app behaves identically:
118
-
119
- ```ts
120
- import { resolveRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
121
-
122
- const resolved = await resolveRecord({
123
- appId,
124
- recordType: 'nutrition',
125
- scope: { collectionId, productId, variantId, batchId, proofId },
126
- });
127
- // → { record, source: 'variant' | 'product' | 'facet' | … } | null
128
- ```
129
-
130
- Apps that only support a subset of scopes pass `supportedScopes: ['product', 'facet']` and the resolver skips the rest.
131
-
132
- ---
133
-
134
- ## 5. Scope capabilities (declared per app)
135
-
136
- An app declares which scopes it accepts records for in `app.manifest.json`. This drives the admin UI and avoids dead tabs:
137
-
138
- ```json
139
- // app.manifest.json (extension)
140
- {
141
- "records": {
142
- "nutrition": {
143
- "scopes": ["product", "facet", "batch"],
144
- "defaultScope": "facet",
145
- "label": "Nutrition info"
146
- }
147
- }
148
- }
149
- ```
150
-
151
- | Field | Meaning |
152
- |----------------|----------------------------------------------------------------|
153
- | `scopes` | Allowed scope kinds, in **resolution order**. |
154
- | `defaultScope` | Where the "Create new" button lands in the admin shell. |
155
- | `label` | Human-readable label for the record type (used in headings). |
156
-
157
- The shared admin shell reads this manifest entry and renders only the relevant tabs. Apps don't hard-code tab lists.
158
-
159
- See [app-manifest.md](app-manifest.md) for the full schema reference.
160
-
161
- ---
162
-
163
- ## 6. Discovering whether variants / batches are in use
164
-
165
- The `Collection` object exposes top-level `variants: boolean` and `batches: boolean` flags that indicate whether the collection has these features enabled. Read them directly rather than probing by listing:
166
-
167
- ```ts
168
- import { appConfiguration } from '@proveanything/smartlinks';
169
-
170
- const collection = await appConfiguration.getCollection(collectionId);
171
-
172
- const showVariantTab = collection.variants && scopeConfig?.scopes.includes('variant') === true;
173
- const showBatchTab = collection.batches && scopeConfig?.scopes.includes('batch') === true;
174
- ```
175
-
176
- `scopeConfig` is the parsed manifest `records` entry for this `recordType` — e.g. `manifest.records?.nutrition`.
177
-
178
- Rules:
179
-
180
- 1. If the collection has the feature **and** the app **declares** support for the scope, show the tab and offer "Add variant" / "Add batch" affordances.
181
- 2. If the app does **not** declare support, hide the tab entirely even if `collection.variants` is true (another app created them).
182
- 3. If the collection does not have the feature enabled, hide the tab even if the app declares support.
183
-
184
- ---
185
-
186
- ## 7. Inheritance & overrides
187
-
188
- Because resolution is first-match-wins, the admin UI must make inheritance **visible**:
189
-
190
- - When editing a **variant** record, show the **product** record as the inherited baseline.
191
- - Each field in the editor displays a small **↩ "Inherited"** marker when its value matches the parent and **● "Override"** when it differs.
192
- - A row-level "Reset to inherited" action removes the override (deletes the record at the current scope if all overrides are reset).
193
-
194
- Apps don't have to implement this themselves — the `<RecordEditor>` primitive in `@proveanything/smartlinks-utils-ui` does it given the resolved parent payload.
195
-
196
- ---
197
-
198
- ## 8. Bulk operations
199
-
200
- Standard verbs every shell should expose:
201
-
202
- | Verb | Behaviour |
203
- |-------------------|----------------------------------------------------------------------|
204
- | **Apply to many** | Take the current record's payload, write it to N selected products / variants. |
205
- | **Copy from** | Pick a source scope, copy its payload to the current scope. |
206
- | **Clear** | Delete records at the current scope (children unaffected). |
207
-
208
- Use the `bulkUpsert` / `bulkDelete` helpers from `@proveanything/smartlinks-utils-ui/records-admin`, which handle batching and error collection for you:
209
-
210
- ```ts
211
- import { bulkUpsert, bulkDelete } from '@proveanything/smartlinks-utils-ui/records-admin';
212
-
213
- // Apply current payload to many refs
214
- await bulkUpsert({ SL, collectionId, appId, recordType, refs: targetRefs, data: payload });
215
-
216
- // Clear records at selected refs
217
- await bulkDelete({ SL, collectionId, appId, recordType, refs: targetRefs });
218
- ```
219
-
220
- If you are using `<RecordsAdminShell>`, the bulk actions menu is included and wired automatically via the `onTelemetry` hook — you don't call these directly unless you're building a custom shell.
221
-
222
- ---
223
-
224
- ## 9. CSV import / export
225
-
226
- If your app exposes CSV import/export, use this column shape so files round-trip across apps:
227
-
228
- ```
229
- scope,scopeRef,<field1>,<field2>,...
230
- product,prod_abc,250,12.5,...
231
- variant,prod_abc/var_500ml,260,12.5,...
232
- batch,prod_abc/B-2024-03,255,12.5,...
233
- facet,bread_type/sourdough,240,11.0,...
234
- ```
235
-
236
- - `scope` is the kind; `scopeRef` is the human-readable reference (the shell maps it to the canonical `ref`).
237
- - Validation errors return a downloadable annotated CSV with an `error` column appended.
238
-
239
- #### If you ship CSV
240
-
241
- - Round-tripping (export reimport unchanged) must be a no-op.
242
-
243
- ---
244
-
245
- ## 10. Public-side hook contract
246
-
247
- To keep widgets consistent across apps, expose one hook per record type (from `@proveanything/smartlinks-utils-ui`):
248
-
249
- ```ts
250
- import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
251
-
252
- const { data, source, isLoading } = useResolvedRecord({
253
- SL,
254
- appId,
255
- recordType: 'nutrition',
256
- // any combination of these — the hook walks the chain:
257
- collectionId, productId, variantId, batchId, proofId,
258
- });
259
- ```
260
-
261
- `source` is one of `'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'universal' | null`. UI can show a badge ("Showing batch-specific values") when useful.
262
-
263
- ---
264
-
265
- ## 11. Telemetry
266
-
267
- All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` wires them automatically using `interactions.appendEvent` from the SDK — apps don't call this themselves.
268
-
269
- | Event | Props |
270
- |------------------------|------------------------------------------------------|
271
- | `record.opened` | `appId, recordType, ref, source` |
272
- | `record.saved` | `appId, recordType, ref, fieldsChanged` |
273
- | `record.deleted` | `appId, recordType, ref` |
274
- | `record.bulkApplied` | `appId, recordType, sourceRef, targetCount` |
275
- | `record.imported` | `appId, recordType, rows, errors` |
276
-
277
- ---
278
-
279
- ## 12. Required reading for app authors
280
-
281
- 1. This document.
282
- 2. The companion **[UI utils guide](ui-utils.md)** explains the React primitives (`<RecordsAdminShell>`, `useResolvedRecord`, etc.) that implement this pattern.
283
- 3. [PRODUCT_FACETS_SDK.md](PRODUCT_FACETS_SDK.md) facet model.
284
- 4. [app-data-storage.md](app-data-storage.md) `app.records` surface.
285
-
286
- ## 13. Migration checklist for existing apps
287
-
288
- - [ ] Stop writing per-product data into `appConfiguration`.
289
- - [ ] Move to `app.records` with `recordType` + `ref`.
290
- - [ ] Adopt the `ref` syntax in §3.
291
- - [ ] Add a `records` block to `app.manifest.json`.
292
- - [ ] Replace bespoke admin browser with `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui`.
293
- - [ ] Replace bespoke public hook with `useResolvedRecord`.
294
- - [ ] Remove any "is variants enabled?" config — use `collection.variants` / `collection.batches` flags instead (§6).
295
-
296
- ---
297
-
298
- ## Appendix A `ref` parser reference
299
-
300
- This is the canonical implementation. Copy it into your app or import from `@proveanything/smartlinks-utils-ui/records-admin`.
301
-
302
- ```ts
303
- type ParsedRef =
304
- | { kind: 'universal' }
305
- | { kind: 'product'; productId: string }
306
- | { kind: 'variant'; productId: string; variantId: string }
307
- | { kind: 'batch'; productId: string; batchId: string }
308
- | { kind: 'proof'; proofId: string }
309
- | { kind: 'facet'; facetKey: string; valueKey: string };
310
-
311
- export const buildRef = (p: ParsedRef): string => {
312
- switch (p.kind) {
313
- case 'universal': return '';
314
- case 'product': return `product:${p.productId}`;
315
- case 'variant': return `variant:${p.productId}:${p.variantId}`;
316
- case 'batch': return `batch:${p.productId}:${p.batchId}`;
317
- case 'proof': return `proof:${p.proofId}`;
318
- case 'facet': return `facet:${p.facetKey}:${p.valueKey}`;
319
- }
320
- };
321
-
322
- export const parseRef = (ref: string): ParsedRef | null => {
323
- if (!ref) return { kind: 'universal' };
324
- const [head, ...rest] = ref.split(':');
325
- switch (head) {
326
- case 'product': return rest.length === 1 ? { kind: 'product', productId: rest[0] } : null;
327
- case 'variant': return rest.length === 2 ? { kind: 'variant', productId: rest[0], variantId: rest[1] } : null;
328
- case 'batch': return rest.length === 2 ? { kind: 'batch', productId: rest[0], batchId: rest[1] } : null;
329
- case 'proof': return rest.length === 1 ? { kind: 'proof', proofId: rest[0] } : null;
330
- case 'facet': return rest.length >= 2 ? { kind: 'facet', facetKey: rest[0], valueKey: rest.slice(1).join(':') } : null;
331
- default: return null;
332
- }
333
- };
334
- ```
1
+ # SmartLinks Records Admin & Public Pattern
2
+
3
+ > Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
4
+ >
5
+ > Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …).
6
+ >
7
+ > Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate.
8
+ >
9
+ > SDK: `@proveanything/smartlinks` ≥ **1.11**, `@proveanything/smartlinks-utils-ui` ≥ **0.7.6**.
10
+
11
+ ---
12
+
13
+ ## 0. TL;DR pick your shape, then copy the snippet
14
+
15
+ Every records-based app fits into a 2×2:
16
+
17
+ | | **Singleton** (one record per scope) | **Collection** (many records per scope) |
18
+ | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------- |
19
+ | **Best-match (one wins)**| Ingredients, nutrition, washing instructions | _(rare usually you want all)_ |
20
+ | **All matches (aggregate)** | _(rare — usually you want best)_ | FAQs, recipes, SOPs, care tips, story cards |
21
+
22
+ That choice drives **three** things and nothing else:
23
+
24
+ 1. **Manifest:** `cardinality: 'singleton' | 'collection'` and `allowFacetRules: boolean`.
25
+ 2. **Admin (`<RecordsAdminShell>`):** pass `cardinality` + include `'rule'` in `scopes` if `allowFacetRules`.
26
+ 3. **Public hook:** `useResolvedRecord` (best match) **or** `useCollectedRecords` / `useResolveAllRecords` (all matches).
27
+
28
+ If you only remember one rule: **never write your own resolver**. The shell, the hooks, and the SDK already agree on resolution order. Reimplementing it is how apps drift.
29
+
30
+ ---
31
+
32
+ ## 1. The data model in one paragraph
33
+
34
+ A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
35
+
36
+ ```ts
37
+ import * as SL from '@proveanything/smartlinks';
38
+
39
+ await SL.app.records.upsert(collectionId, appId, {
40
+ recordType: 'ingredients',
41
+ scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref
42
+ data: { /* domain payload */ },
43
+ }, /* admin */ true);
44
+ ```
45
+
46
+ Or, for a rule-targeted record:
47
+
48
+ ```ts
49
+ await SL.app.records.upsert(collectionId, appId, {
50
+ recordType: 'ingredients',
51
+ facetRule: {
52
+ all: [
53
+ { facetKey: 'brand', anyOf: ['acme'] },
54
+ { facetKey: 'category', anyOf: ['bread', 'pastry'] },
55
+ ],
56
+ },
57
+ data: { /* domain payload */ },
58
+ }, true);
59
+ ```
60
+
61
+ `scope` and `facetRule` are **mutually exclusive on save**.
62
+
63
+ ---
64
+
65
+ ## 2. Resolution order (one canonical chain)
66
+
67
+ The server walks **most-specific → least-specific** and stops at the first match (for best-match) or collects every match (for aggregate):
68
+
69
+ ```
70
+ proof → batch → variant → product → rule(*) → facet(*) → collection
71
+ ```
72
+
73
+ - `rule(*)` — facet-rule records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins.
74
+ - `facet(*)` legacy single-facet anchors, walked deterministically (alphabetical).
75
+ - `collection` — the top of the chain. **There is no "global" tier above collection.** A collection-level record is the catch-all for that collection.
76
+
77
+ The resolved value comes back tagged with `matchedAt: 'product' | 'rule' | 'facet' | …` so the UI can say things like _"Matched by rule: brand=Acme AND category=bread"_.
78
+
79
+ > ⚠️ Legacy `scope.facets[]` (colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. Use `facetRule` for everything that isn't a one-off facet pin.
80
+
81
+ ---
82
+
83
+ ## 3. Manifest declaration
84
+
85
+ Declare each record type once in `app.admin.json`. The shell and the platform read this to render the right scope tabs and disable the wrong affordances.
86
+
87
+ ```json
88
+ {
89
+ "records": {
90
+ "ingredients": {
91
+ "label": "Ingredients",
92
+ "cardinality": "singleton",
93
+ "allowFacetRules": true,
94
+ "scopes": ["collection", "facet", "rule", "product", "variant", "batch"],
95
+ "defaultScope": "product"
96
+ },
97
+ "faq": {
98
+ "label": "FAQs",
99
+ "cardinality": "collection",
100
+ "allowFacetRules": true,
101
+ "scopes": ["collection", "rule", "product"],
102
+ "defaultScope": "collection"
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ | Field | Meaning |
109
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
110
+ | `cardinality` | `'singleton'` (one per scope, e.g. ingredients) or `'collection'` (many per scope, e.g. FAQs). Default `'singleton'`. |
111
+ | `allowFacetRules` | `true` to enable the `rule` scope tab + `<FacetRuleEditor>` in the shell. Default `false`. |
112
+ | `scopes` | Allowed scope kinds in **resolution order**. `'rule'` is a synthetic scope that holds rule-targeted records. |
113
+ | `defaultScope` | Where the "Create new" button lands. |
114
+ | `label` | Human-readable label used in headings and toasts. |
115
+
116
+ ---
117
+
118
+ ## 4. Admin side — `<RecordsAdminShell>` (the only thing you should be writing)
119
+
120
+ The shell owns: scope tabs, browser pane, rule editor, save/discard, dirty navigation, inheritance markers, deletion, CSV, bulk apply, deep linking. **You only supply the editor for one record's `data`.**
121
+
122
+ ```tsx
123
+ import * as SL from '@proveanything/smartlinks';
124
+ import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';
125
+
126
+ <RecordsAdminShell<IngredientsConfig>
127
+ SL={SL}
128
+ collectionId={collectionId}
129
+ appId={appId}
130
+ recordType="ingredients"
131
+ label="Ingredients"
132
+ cardinality="singleton" // ← from manifest
133
+ scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']}
134
+ defaultScope="product"
135
+ defaultData={() => emptyConfig()}
136
+ renderEditor={(ctx) => (
137
+ <IngredientsEditor
138
+ value={ctx.value}
139
+ onChange={ctx.onChange}
140
+ // For rule-targeted records, the shell hands you the live rule + setter:
141
+ facetRule={ctx.facetRule}
142
+ onFacetRuleChange={ctx.onFacetRuleChange}
143
+ />
144
+ )}
145
+ />
146
+ ```
147
+
148
+ ### What the shell gives you for free
149
+
150
+ - **Scope tabs** including a **`Rule`** tab when `'rule'` is in `scopes`. Selecting it opens `<FacetRuleEditor>` above your editor — no extra wiring.
151
+ - **`EditorContext.facetRule` / `onFacetRuleChange`** for rule-scoped records, plus `canSave: false` until at least one clause has values (avoids server 500s).
152
+ - **Inheritance markers** — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers.
153
+ - **Collection cardinality flow** — set `cardinality="collection"` and the shell turns the right pane into a list of items (table / cards / gallery) with `+ New` and per-item nav.
154
+ - **Telemetry** `record.save`, `record.delete`, `scope.change`, `csv.import`, `bulk.apply`, `item.create`, etc. via `onTelemetry`.
155
+
156
+ ### Standalone rule editor
157
+
158
+ If you need a rule editor outside the shell (e.g. on a settings page):
159
+
160
+ ```tsx
161
+ import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor';
162
+
163
+ <FacetRuleEditor
164
+ value={rule}
165
+ onChange={setRule}
166
+ collectionId={collectionId} // lazy-fetches facets via SL.facets.publicList
167
+ preview={rulePreview} // optional — wire from useRulePreview
168
+ />
169
+ ```
170
+
171
+ ---
172
+
173
+ ## 5. Public side — pick the right hook (this is where apps go wrong)
174
+
175
+ There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
176
+
177
+ ### 5a. Singleton → `useResolvedRecord` (best match wins)
178
+
179
+ Use this when the app shows **one** answer for the current product (ingredients, nutrition, washing instructions, warranty terms).
180
+
181
+ ```tsx
182
+ import * as SL from '@proveanything/smartlinks';
183
+ import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
184
+
185
+ const { data, source, sourceRef, matchedAt, matchedRule, isLoading } =
186
+ useResolvedRecord<IngredientsConfig>({
187
+ SL,
188
+ appId,
189
+ collectionId,
190
+ recordType: 'ingredients',
191
+ productId,
192
+ variantId, // optional
193
+ batchId, // optional
194
+ proofId, // optional
195
+ });
196
+ ```
197
+
198
+ The resolver walks `proof → batch → variant → product → rule → facet → collection` and returns the **first match**, plus `matchedAt` so you can render _"From the product record"_ vs _"Matched by rule"_ badges if useful.
199
+
200
+ > Wrap this once in your app (e.g. `useResolvedIngredientSet`) so the rest of the codebase reads `{ data, isLoading }` and never sees the resolver.
201
+
202
+ ### 5b. Collection → `useCollectedRecords` (every match, ordered)
203
+
204
+ Use this when the app shows **many** answers aggregated across the chain (FAQs, recipes, SOPs, care tips). Most-specific first by default; pass `sort` to override.
205
+
206
+ ```tsx
207
+ import { useCollectedRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
208
+
209
+ const { items, isLoading } = useCollectedRecords<FaqEntry>({
210
+ SL,
211
+ appId,
212
+ collectionId,
213
+ recordType: 'faq',
214
+ productId,
215
+ // sort: { kind: 'field', field: 'order', direction: 'asc' },
216
+ });
217
+
218
+ // items: CollectedRecord<FaqEntry>[] — each has { data, scope, ref, depth }
219
+ ```
220
+
221
+ ### 5c. Multi-type aggregate → `useResolveAllRecords`
222
+
223
+ When you need every record of every type that applies to a context (rare; mostly executors and SEO surfaces).
224
+
225
+ ```tsx
226
+ import { useResolveAllRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
227
+
228
+ const { entries, isLoading } = useResolveAllRecords({
229
+ SL, collectionId, appId,
230
+ context: { productId, facets: { brand: 'acme', category: ['bread'] } },
231
+ });
232
+ ```
233
+
234
+ ### Common mistakes (do not do these)
235
+
236
+ | Anti-pattern | Do this instead |
237
+ | ---------------------------------------------------------- | ----------------------------------------------------------------- |
238
+ | Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
239
+ | Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
240
+ | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
241
+ | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
242
+ | Building your own `<FacetRuleEditor>` | Use the one from `@proveanything/smartlinks-utils-ui/facet-rule-editor`. |
243
+
244
+ ---
245
+
246
+ ## 6. Reference: the `EditorContext` your `renderEditor` receives
247
+
248
+ ```ts
249
+ interface EditorContext<TData> {
250
+ value: TData;
251
+ onChange: (next: TData) => void;
252
+ source: 'self' | 'inherited' | 'empty';
253
+ recordId?: string;
254
+ parentValue?: TData | null;
255
+ scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
256
+
257
+ // Save lifecycle
258
+ isDirty: boolean;
259
+ isSaving?: boolean;
260
+ saveError?: unknown | null;
261
+ canSave?: boolean; // shell flips to false on empty rules
262
+ cannotSaveReason?: string;
263
+ save: () => Promise<void>;
264
+ reset: () => void;
265
+
266
+ // Deletion
267
+ remove: () => Promise<void>;
268
+ canRemove: boolean;
269
+
270
+ // Rule scope only
271
+ facetRule?: FacetRule | null;
272
+ onFacetRuleChange?: (next: FacetRule | null) => void;
273
+ }
274
+ ```
275
+
276
+ ---
277
+
278
+ ## 7. Migration checklist (existing apps)
279
+
280
+ 1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`.
281
+ 2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`.
282
+ 3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`.
283
+ 4. **Pass `cardinality`** to `<RecordsAdminShell>`.
284
+ 5. **Replace any handwritten chain walking** with `useResolvedRecord` (singleton) or `useCollectedRecords` (collection).
285
+ 6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>`.
286
+ 7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
287
+
288
+ ---
289
+
290
+ ## 8. Where the canonical exports live
291
+
292
+ | Need | Import from |
293
+ | ----------------------------- | ---------------------------------------------------------------------- |
294
+ | Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` `RecordsAdminShell` |
295
+ | Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` |
296
+ | Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` |
297
+ | Best-match resolver hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` |
298
+ | All-matches hook | `@proveanything/smartlinks-utils-ui/records-admin` `useCollectedRecords` |
299
+ | Multi-type aggregate hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` |
300
+ | Rule preview ("matches N") | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` |
301
+ | Server-side record CRUD | `@proveanything/smartlinks` → `SL.app.records.{upsert, list, remove, match, resolveAll}` |
302
+
303
+ ---
304
+
305
+ _End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._