@proveanything/smartlinks 1.11.7 → 1.11.8
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/dist/docs/API_SUMMARY.md +1 -1
- package/dist/docs/app-manifest.md +2 -2
- package/dist/docs/app-objects.md +2 -2
- package/{docs/records-admin-pattern.md → dist/docs/app-records-pattern.md} +26 -2
- package/dist/docs/overview.md +1 -1
- package/dist/docs/ui-utils.md +3 -3
- package/docs/API_SUMMARY.md +1 -1
- package/docs/app-manifest.md +2 -2
- package/docs/app-objects.md +2 -2
- package/docs/app-records-pattern.md +329 -0
- package/docs/overview.md +1 -1
- package/docs/ui-utils.md +3 -3
- package/package.json +1 -1
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
|
|
|
270
270
|
|
|
271
271
|
#### `records`
|
|
272
272
|
|
|
273
|
-
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records
|
|
273
|
+
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
|
|
274
274
|
|
|
275
275
|
The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
|
|
276
276
|
|
|
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
|
|
|
294
294
|
| `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
|
|
295
295
|
| `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
|
|
296
296
|
|
|
297
|
-
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-
|
|
297
|
+
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
|
|
298
298
|
|
|
299
299
|
#### `executor`
|
|
300
300
|
|
package/dist/docs/app-objects.md
CHANGED
|
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
445
445
|
- **Usage logs** — record product usage metrics
|
|
446
446
|
- **Audit trails** — immutable logs of actions
|
|
447
447
|
- **Loyalty points** — track points earned/redeemed
|
|
448
|
-
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-
|
|
448
|
+
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
|
|
449
449
|
|
|
450
450
|
### Key Features
|
|
451
451
|
|
|
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
|
|
|
526
526
|
- `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
|
|
527
527
|
- `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
|
|
528
528
|
|
|
529
|
-
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-
|
|
529
|
+
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
|
|
530
530
|
|
|
531
531
|
### Singleton Cardinality
|
|
532
532
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# SmartLinks Records
|
|
1
|
+
# SmartLinks App Records Pattern
|
|
2
2
|
|
|
3
3
|
> Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
|
|
4
4
|
>
|
|
@@ -172,6 +172,26 @@ import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-e
|
|
|
172
172
|
|
|
173
173
|
## 5. Public side — pick the right hook (this is where apps go wrong)
|
|
174
174
|
|
|
175
|
+
> **Admin vs public — the rule is simple:**
|
|
176
|
+
>
|
|
177
|
+
> | Function | Public widget | Admin dashboard |
|
|
178
|
+
> |---|---|---|
|
|
179
|
+
> | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
180
|
+
> | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
181
|
+
> | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
182
|
+
> | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
183
|
+
> | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
184
|
+
> | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
185
|
+
> | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
186
|
+
> | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
187
|
+
> | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
|
|
188
|
+
> | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
|
|
189
|
+
> | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
|
|
190
|
+
> | `app.records.restore()` | ❌ admin only — no public path | ✅ |
|
|
191
|
+
> | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
|
|
192
|
+
>
|
|
193
|
+
> The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
|
|
194
|
+
|
|
175
195
|
There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
|
|
176
196
|
|
|
177
197
|
### 5a. Singleton → `useResolvedRecord` (best match wins)
|
|
@@ -236,6 +256,10 @@ const { entries, isLoading } = useResolveAllRecords({
|
|
|
236
256
|
| ❌ Anti-pattern | ✅ Do this instead |
|
|
237
257
|
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
238
258
|
| Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
|
|
259
|
+
| Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
|
|
260
|
+
| Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
|
|
261
|
+
| Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
|
|
262
|
+
| Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
|
|
239
263
|
| Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
|
|
240
264
|
| Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
|
|
241
265
|
| Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
|
|
@@ -302,4 +326,4 @@ interface EditorContext<TData> {
|
|
|
302
326
|
|
|
303
327
|
---
|
|
304
328
|
|
|
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._
|
|
329
|
+
_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._
|
package/dist/docs/overview.md
CHANGED
|
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
70
70
|
| **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
|
|
71
71
|
| **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
|
|
72
72
|
| **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
|
|
73
|
-
| **Records
|
|
73
|
+
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
|
|
74
74
|
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
|
|
75
75
|
|
|
76
76
|
---
|
package/dist/docs/ui-utils.md
CHANGED
|
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
|
|
|
19
19
|
|
|
20
20
|
**When do you need it?**
|
|
21
21
|
|
|
22
|
-
- You are building an admin UI for a records-based microapp (see [records-
|
|
22
|
+
- You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
|
|
23
23
|
- You need a media asset picker, icon picker, or font picker in an admin panel
|
|
24
24
|
- You need a recursive rule/conditions editor for targeting or audience logic
|
|
25
25
|
- You want the standard inheritance/override editor for scoped records
|
|
@@ -326,7 +326,7 @@ Props:
|
|
|
326
326
|
|
|
327
327
|
Free-text facet entry is **not** supported — admins must pick from defined facets.
|
|
328
328
|
|
|
329
|
-
See [records-
|
|
329
|
+
See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
|
|
330
330
|
|
|
331
331
|
---
|
|
332
332
|
|
|
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
|
|
|
505
505
|
|
|
506
506
|
## Further reading
|
|
507
507
|
|
|
508
|
-
- [records-
|
|
508
|
+
- [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
|
|
509
509
|
- [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
|
|
510
510
|
- [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
|
package/docs/API_SUMMARY.md
CHANGED
package/docs/app-manifest.md
CHANGED
|
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
|
|
|
270
270
|
|
|
271
271
|
#### `records`
|
|
272
272
|
|
|
273
|
-
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records
|
|
273
|
+
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
|
|
274
274
|
|
|
275
275
|
The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
|
|
276
276
|
|
|
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
|
|
|
294
294
|
| `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
|
|
295
295
|
| `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
|
|
296
296
|
|
|
297
|
-
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-
|
|
297
|
+
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
|
|
298
298
|
|
|
299
299
|
#### `executor`
|
|
300
300
|
|
package/docs/app-objects.md
CHANGED
|
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
445
445
|
- **Usage logs** — record product usage metrics
|
|
446
446
|
- **Audit trails** — immutable logs of actions
|
|
447
447
|
- **Loyalty points** — track points earned/redeemed
|
|
448
|
-
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-
|
|
448
|
+
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
|
|
449
449
|
|
|
450
450
|
### Key Features
|
|
451
451
|
|
|
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
|
|
|
526
526
|
- `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
|
|
527
527
|
- `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
|
|
528
528
|
|
|
529
|
-
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-
|
|
529
|
+
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
|
|
530
530
|
|
|
531
531
|
### Singleton Cardinality
|
|
532
532
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# SmartLinks App Records 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
|
+
> **Admin vs public — the rule is simple:**
|
|
176
|
+
>
|
|
177
|
+
> | Function | Public widget | Admin dashboard |
|
|
178
|
+
> |---|---|---|
|
|
179
|
+
> | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
180
|
+
> | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
181
|
+
> | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
182
|
+
> | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
183
|
+
> | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
184
|
+
> | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
185
|
+
> | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
186
|
+
> | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
187
|
+
> | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
|
|
188
|
+
> | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
|
|
189
|
+
> | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
|
|
190
|
+
> | `app.records.restore()` | ❌ admin only — no public path | ✅ |
|
|
191
|
+
> | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
|
|
192
|
+
>
|
|
193
|
+
> The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
|
|
194
|
+
|
|
195
|
+
There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
|
|
196
|
+
|
|
197
|
+
### 5a. Singleton → `useResolvedRecord` (best match wins)
|
|
198
|
+
|
|
199
|
+
Use this when the app shows **one** answer for the current product (ingredients, nutrition, washing instructions, warranty terms).
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import * as SL from '@proveanything/smartlinks';
|
|
203
|
+
import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
204
|
+
|
|
205
|
+
const { data, source, sourceRef, matchedAt, matchedRule, isLoading } =
|
|
206
|
+
useResolvedRecord<IngredientsConfig>({
|
|
207
|
+
SL,
|
|
208
|
+
appId,
|
|
209
|
+
collectionId,
|
|
210
|
+
recordType: 'ingredients',
|
|
211
|
+
productId,
|
|
212
|
+
variantId, // optional
|
|
213
|
+
batchId, // optional
|
|
214
|
+
proofId, // optional
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
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.
|
|
219
|
+
|
|
220
|
+
> Wrap this once in your app (e.g. `useResolvedIngredientSet`) so the rest of the codebase reads `{ data, isLoading }` and never sees the resolver.
|
|
221
|
+
|
|
222
|
+
### 5b. Collection → `useCollectedRecords` (every match, ordered)
|
|
223
|
+
|
|
224
|
+
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.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useCollectedRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
228
|
+
|
|
229
|
+
const { items, isLoading } = useCollectedRecords<FaqEntry>({
|
|
230
|
+
SL,
|
|
231
|
+
appId,
|
|
232
|
+
collectionId,
|
|
233
|
+
recordType: 'faq',
|
|
234
|
+
productId,
|
|
235
|
+
// sort: { kind: 'field', field: 'order', direction: 'asc' },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// items: CollectedRecord<FaqEntry>[] — each has { data, scope, ref, depth }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 5c. Multi-type aggregate → `useResolveAllRecords`
|
|
242
|
+
|
|
243
|
+
When you need every record of every type that applies to a context (rare; mostly executors and SEO surfaces).
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
import { useResolveAllRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
247
|
+
|
|
248
|
+
const { entries, isLoading } = useResolveAllRecords({
|
|
249
|
+
SL, collectionId, appId,
|
|
250
|
+
context: { productId, facets: { brand: 'acme', category: ['bread'] } },
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Common mistakes (do not do these)
|
|
255
|
+
|
|
256
|
+
| ❌ Anti-pattern | ✅ Do this instead |
|
|
257
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
258
|
+
| Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
|
|
259
|
+
| Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
|
|
260
|
+
| Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
|
|
261
|
+
| Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
|
|
262
|
+
| Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
|
|
263
|
+
| Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
|
|
264
|
+
| Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
|
|
265
|
+
| Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
|
|
266
|
+
| Building your own `<FacetRuleEditor>` | Use the one from `@proveanything/smartlinks-utils-ui/facet-rule-editor`. |
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 6. Reference: the `EditorContext` your `renderEditor` receives
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
interface EditorContext<TData> {
|
|
274
|
+
value: TData;
|
|
275
|
+
onChange: (next: TData) => void;
|
|
276
|
+
source: 'self' | 'inherited' | 'empty';
|
|
277
|
+
recordId?: string;
|
|
278
|
+
parentValue?: TData | null;
|
|
279
|
+
scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
|
|
280
|
+
|
|
281
|
+
// Save lifecycle
|
|
282
|
+
isDirty: boolean;
|
|
283
|
+
isSaving?: boolean;
|
|
284
|
+
saveError?: unknown | null;
|
|
285
|
+
canSave?: boolean; // shell flips to false on empty rules
|
|
286
|
+
cannotSaveReason?: string;
|
|
287
|
+
save: () => Promise<void>;
|
|
288
|
+
reset: () => void;
|
|
289
|
+
|
|
290
|
+
// Deletion
|
|
291
|
+
remove: () => Promise<void>;
|
|
292
|
+
canRemove: boolean;
|
|
293
|
+
|
|
294
|
+
// Rule scope only
|
|
295
|
+
facetRule?: FacetRule | null;
|
|
296
|
+
onFacetRuleChange?: (next: FacetRule | null) => void;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 7. Migration checklist (existing apps)
|
|
303
|
+
|
|
304
|
+
1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`.
|
|
305
|
+
2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`.
|
|
306
|
+
3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`.
|
|
307
|
+
4. **Pass `cardinality`** to `<RecordsAdminShell>`.
|
|
308
|
+
5. **Replace any handwritten chain walking** with `useResolvedRecord` (singleton) or `useCollectedRecords` (collection).
|
|
309
|
+
6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>`.
|
|
310
|
+
7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## 8. Where the canonical exports live
|
|
315
|
+
|
|
316
|
+
| Need | Import from |
|
|
317
|
+
| ----------------------------- | ---------------------------------------------------------------------- |
|
|
318
|
+
| Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` → `RecordsAdminShell` |
|
|
319
|
+
| Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` |
|
|
320
|
+
| Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` |
|
|
321
|
+
| Best-match resolver hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` |
|
|
322
|
+
| All-matches hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useCollectedRecords` |
|
|
323
|
+
| Multi-type aggregate hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` |
|
|
324
|
+
| Rule preview ("matches N") | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` |
|
|
325
|
+
| Server-side record CRUD | `@proveanything/smartlinks` → `SL.app.records.{upsert, list, remove, match, resolveAll}` |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
_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._
|
package/docs/overview.md
CHANGED
|
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
70
70
|
| **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
|
|
71
71
|
| **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
|
|
72
72
|
| **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
|
|
73
|
-
| **Records
|
|
73
|
+
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
|
|
74
74
|
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
|
|
75
75
|
|
|
76
76
|
---
|
package/docs/ui-utils.md
CHANGED
|
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
|
|
|
19
19
|
|
|
20
20
|
**When do you need it?**
|
|
21
21
|
|
|
22
|
-
- You are building an admin UI for a records-based microapp (see [records-
|
|
22
|
+
- You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
|
|
23
23
|
- You need a media asset picker, icon picker, or font picker in an admin panel
|
|
24
24
|
- You need a recursive rule/conditions editor for targeting or audience logic
|
|
25
25
|
- You want the standard inheritance/override editor for scoped records
|
|
@@ -326,7 +326,7 @@ Props:
|
|
|
326
326
|
|
|
327
327
|
Free-text facet entry is **not** supported — admins must pick from defined facets.
|
|
328
328
|
|
|
329
|
-
See [records-
|
|
329
|
+
See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
|
|
330
330
|
|
|
331
331
|
---
|
|
332
332
|
|
|
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
|
|
|
505
505
|
|
|
506
506
|
## Further reading
|
|
507
507
|
|
|
508
|
-
- [records-
|
|
508
|
+
- [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
|
|
509
509
|
- [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
|
|
510
510
|
- [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
|