App Records Pattern
Canonical guide for microapps storing per-product, per-variant, per-batch, or rule-targeted data.
SmartLinks App Records Pattern
Canonical guide for microapps that store per-product, per-facet, per-variant, per-batch, or rule-targeted data.
Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …).
Status: standard. New apps MUST follow this contract; existing apps SHOULD migrate.
SDK:
@proveanything/smartlinks≥ 1.11. Admin shell (React only):@proveanything/smartlinks-utils-ui≥ 0.7.6 — required for the admin side if using the React shell; not needed in public widgets.
0. TL;DR — pick your shape, then copy the snippet
Every records-based app fits into a 2×2:
| Singleton (one record per scope) | Collection (many records per scope) | |
|---|---|---|
| Best-match (one wins) | Ingredients, nutrition, washing instructions | (rare — usually you want all) |
| All matches (aggregate) | (rare — usually you want best) | FAQs, recipes, SOPs, care tips, story cards |
That choice drives three things and nothing else:
- Manifest:
cardinality: 'singleton' | 'collection'andallowFacetRules: boolean. - Admin: use
<RecordsAdminShell>(React) or call the admin SDK functions directly. Passcardinality+ include'rule'inscopesifallowFacetRules. - Public widget: call
app.records.match()(best match / singleton) orapp.records.resolveAll()(all matches / collection). These are plain SDK calls with no framework dependency.
If you only remember one rule: never write your own resolution loop. The server already walks the chain correctly — calling match() or resolveAll() is the entire public-side implementation.
1. The data model in one paragraph
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). Records also carry a status ('active' | 'draft' | 'archived') and optional startsAt / expiresAt timestamps. 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.
import * as SL from '@proveanything/smartlinks';
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref
data: { /* domain payload */ },
}, /* admin */ true);
Or, for a rule-targeted record:
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
facetRule: {
all: [
{ facetKey: 'brand', anyOf: ['acme'] },
{ facetKey: 'category', anyOf: ['bread', 'pastry'] },
],
},
data: { /* domain payload */ },
}, true);
scope and facetRule are mutually exclusive on save.
2. Resolution order (one canonical chain)
The server walks most-specific → least-specific and stops at the first match (for best-match) or collects every match (for aggregate):
proof → batch → variant → product → rule(*) → facet(*) → collection
rule(*)— facet-rule records are scored by specificity (number of clauses + number of constrained values). The most specific rule wins.facet(*)— legacy single-facet anchors, walked deterministically (alphabetical).collection— the top of the chain. There is no "global" tier above collection. A collection-level record is the catch-all for that collection.
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".
⚠️ Legacy
scope.facets[](colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. UsefacetRulefor everything that isn't a one-off facet pin.
3. Manifest declaration
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.
{
"records": {
"ingredients": {
"label": "Ingredients",
"cardinality": "singleton",
"allowFacetRules": true,
"scopes": ["collection", "facet", "rule", "product", "variant", "batch"],
"defaultScope": "product"
},
"faq": {
"label": "FAQs",
"cardinality": "collection",
"allowFacetRules": true,
"scopes": ["collection", "rule", "product"],
"defaultScope": "collection"
}
}
}
| Field | Meaning |
|---|---|
cardinality | 'singleton' (one per scope, e.g. ingredients) or 'collection' (many per scope, e.g. FAQs). Default 'singleton'. |
allowFacetRules | true to enable the rule scope tab + <FacetRuleEditor> in the shell. Default false. |
scopes | Allowed scope kinds in resolution order. 'rule' is a synthetic scope that holds rule-targeted records. |
defaultScope | Where the "Create new" button lands. |
label | Human-readable label used in headings and toasts. |
4. Admin side
The admin shell and rule editor are part of
@proveanything/smartlinks-utils-ui, which is a React-only library. It is only needed in admin dashboards — never import it in a public widget.
<RecordsAdminShell> (React)
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.
import * as SL from '@proveanything/smartlinks';
import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';
<RecordsAdminShell<IngredientsConfig>
SL={SL}
collectionId={collectionId}
appId={appId}
recordType="ingredients"
label="Ingredients"
cardinality="singleton" // ← from manifest
scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']}
defaultScope="product"
defaultData={() => emptyConfig()}
renderEditor={(ctx) => (
<IngredientsEditor
value={ctx.value}
onChange={ctx.onChange}
// For rule-targeted records, the shell hands you the live rule + setter:
facetRule={ctx.facetRule}
onFacetRuleChange={ctx.onFacetRuleChange}
/>
)}
/>
What the shell gives you for free
- Scope tabs including a
Ruletab when'rule'is inscopes. Selecting it opens<FacetRuleEditor>above your editor — no extra wiring. EditorContext.facetRule/onFacetRuleChangefor rule-scoped records, pluscanSave: falseuntil at least one clause has values (avoids server 500s).- Inheritance markers — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers.
- Collection cardinality flow — set
cardinality="collection"and the shell turns the right pane into a list of items (table / cards / gallery) with+ Newand per-item nav. - Telemetry —
record.save,record.delete,scope.change,csv.import,bulk.apply,item.create, etc. viaonTelemetry.
Standalone rule editor
If you need a rule editor outside the shell (e.g. on a settings page):
import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor';
<FacetRuleEditor
value={rule}
onChange={setRule}
collectionId={collectionId} // lazy-fetches facets via SL.facets.publicList
preview={rulePreview} // optional — wire from useRulePreview
/>
5. Public side
Do not import
@proveanything/smartlinks-utils-uiin a public widget. It is a React admin library. Public widgets only need@proveanything/smartlinks.
The SDK is framework-agnostic. Public widgets call two endpoints depending on cardinality:
| Cardinality | Call | What it does |
|---|---|---|
| Singleton (one answer) | app.records.match() | Server walks the chain, returns the best-matching record |
| Collection (all answers) | app.records.resolveAll() | Server walks the chain, returns every matching record |
Neither call requires React or any other framework — wrap them in whatever async pattern your widget uses.
Admin vs public — the rule is simple:
Function Public widget Admin dashboard app.records.create(…, false)✅ default — omit the flag ✅ pass trueapp.records.list(…, false)✅ default — omit the flag ✅ pass trueapp.records.get(…, false)✅ default — omit the flag ✅ pass trueapp.records.update(…, false)✅ default — omit the flag ✅ pass trueapp.records.remove(…, false)✅ default — omit the flag ✅ pass trueapp.records.aggregate(…, false)✅ default — omit the flag ✅ pass trueapp.records.match(…, false)✅ default — omit the flag ✅ pass trueapp.records.resolveAll(…, false)✅ default — omit the flag ✅ pass trueapp.records.upsert()❌ admin only — no public path ✅ app.records.bulkUpsert()❌ admin only — no public path ✅ app.records.bulkDelete()❌ admin only — no public path ✅ app.records.restore()❌ admin only — no public path ✅ app.records.previewRule()❌ admin only — no public path ✅
5a. Singleton — app.records.match() (best match wins)
Use when the widget shows one answer for the current product (ingredients, nutrition, warranty terms, washing instructions).
import * as SL from '@proveanything/smartlinks';
const result = await SL.app.records.match(collectionId, appId, {
target: { productId, variantId, batchId }, // pass whatever context you have
strategy: 'best',
recordType: 'ingredients',
});
// result.data[0] → the single highest-specificity MatchEntry (when strategy: 'best')
// result.data[0].matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
// result.data[0].data → your record payload
The server walks proof → batch → variant → product → rule → facet → collection and returns the first match. result.data will have at most one entry when strategy: 'best'.
5b. Collection — app.records.resolveAll() (every match, ordered)
Use when the widget shows many answers across the chain (FAQs, recipes, care tips, SOPs).
const result = await SL.app.records.resolveAll(collectionId, appId, {
context: { productId }, // note: resolveAll uses 'context', not 'target'
recordType: 'faq', // singular — omit to return all record types
});
// result.records → ResolveAllEntry[] sorted most-specific first
// each entry: { record: AppRecord, matchedAt, specificity, matchedRule? }
5c. Multi-type — app.records.resolveAll() without a recordType filter
When you need records of all types for a context in one call (rare; executors, SEO surfaces), omit recordType:
const result = await SL.app.records.resolveAll(collectionId, appId, {
context: {
productId,
facets: { brand: ['acme'] }, // include facets to match rule records
},
// no recordType → returns all declared types
});
// result.records → one ResolveAllEntry per matched record, all types interleaved
// filter client-side by entry.record.recordType if you need to separate them
5d. Status filtering and the draft → active lifecycle
Every record has a status field with three canonical values:
| Value | Meaning | Returned to public/owner callers? |
|---|---|---|
active | Live and current | ✅ Yes |
draft | Being prepared, not yet published | ❌ No |
archived | Previously live, retained for history | ❌ No |
Enforcement: match(), resolveAll(), and GET /records (query) now only return status: "active" records to public and owner callers. Admin callers receive all statuses as before; use explicit status filters (status=draft, status=archived) to narrow results.
active is the default when no status is supplied on creation, so existing records and simple creation flows are unaffected.
Draft → publish workflow: create the record with status: 'draft' so it is invisible to public widgets, then update it to status: 'active' when ready to publish.
// Create a record that is not yet publicly visible
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId },
data: { /* draft payload */ },
status: 'draft',
}, /* admin */ true);
// Publish it
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId },
data: { /* final payload */ },
status: 'active',
}, true);
Composes with startsAt / expiresAt: a record must satisfy both the status check and the time window to be returned to public callers. A record that is active but whose startsAt is in the future, or whose expiresAt has passed, is excluded.
Common mistakes (do not do these)
| ❌ Anti-pattern | ✅ Do this instead |
|---|---|
Importing anything from @proveanything/smartlinks-utils-ui in a public widget | That package is React-only and admin-only. Public widgets only use @proveanything/smartlinks. |
Calling SL.app.records.list() and filtering client-side | app.records.match() (singleton) or app.records.resolveAll() (collection). The server walks the chain. |
Calling SL.app.records.list(…, true) from a public widget | Omit the admin flag — it defaults to false. |
Calling SL.app.records.match(…, true) from a public widget | Omit the admin flag — it defaults to false. |
Calling SL.app.records.resolveAll(…, true) from a public widget | Omit the admin flag — it defaults to false. |
Calling upsert, bulkUpsert, bulkDelete, or previewRule from widget code | Those are admin-only. Widget code reads data; it never writes records. |
Walking the chain by hand with multiple get / list calls | One match() or resolveAll() call. The server handles the resolution order including rules. |
Treating facet:key:value refs as the rule mechanism | Use facetRule ({ all: [{ facetKey, anyOf: [...] }] }). Multi-condition, scored by specificity. |
Reading matchedAt === 'global' | There is no 'global'. The top of the chain is 'collection'. |
Expecting draft or archived records to appear in public widget results | Public/owner callers only receive status: "active" records from match(), resolveAll(), and GET /records. Use admin calls to query by other statuses. |
Setting status: 'active' and wondering why a record is still hidden | Check startsAt / expiresAt — a record must satisfy both the status check and the time window. |
6. Reference: the EditorContext your renderEditor receives
interface EditorContext<TData> {
value: TData;
onChange: (next: TData) => void;
source: 'self' | 'inherited' | 'empty';
recordId?: string;
parentValue?: TData | null;
scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
// Save lifecycle
isDirty: boolean;
isSaving?: boolean;
saveError?: unknown | null;
canSave?: boolean; // shell flips to false on empty rules
cannotSaveReason?: string;
save: () => Promise<void>;
reset: () => void;
// Deletion
remove: () => Promise<void>;
canRemove: boolean;
// Rule scope only
facetRule?: FacetRule | null;
onFacetRuleChange?: (next: FacetRule | null) => void;
}
7. Migration checklist (existing apps)
- Update SDKs:
@proveanything/smartlinks@^1.11,@proveanything/smartlinks-utils-ui@^0.7.6. - Add
cardinalityandallowFacetRulesto every entry underrecordsinapp.admin.json. - Add
'rule'(and'collection'if missing) toscopeswhereverallowFacetRules: true. - Pass
cardinalityto<RecordsAdminShell>. - Replace any handwritten chain walking with
app.records.match()(singleton) orapp.records.resolveAll()(collection). If you are using React, theuseResolvedRecord/useCollectedRecordshooks from@proveanything/smartlinks-utils-uiwrap these calls — but they are admin-side React helpers, not for public widgets. - Delete any code that constructs
facet:key:valuerefs for matching. UsefacetRulevia the shell or<FacetRuleEditor>(React admin) or passfacetRuledirectly inupsert()calls. - Search for the word "global" in your code/docs and rename to "collection" — this is the most common source of confusion.
- Audit records that should not be public yet: any record that previously relied on obscurity (e.g. not linked in the widget, no active product) is now filtered by
status. Setstatus: 'draft'on records that are not ready andstatus: 'active'when publishing. Records without an explicit status were created asactiveand are unaffected.
8. Where the canonical exports live
Public widgets (any framework)
| Need | Import from |
|---|---|
| Best-match resolution (singleton) | @proveanything/smartlinks → SL.app.records.match() |
| All-matches resolution (collection) | @proveanything/smartlinks → SL.app.records.resolveAll() |
| Record CRUD (public path) | @proveanything/smartlinks → SL.app.records.{list, get, create, update, remove, aggregate} |
Admin dashboards (React)
All of the following are from
@proveanything/smartlinks-utils-ui, a React-only package. Do not use in public widgets.
| Need | Import from |
|---|---|
| Admin shell | @proveanything/smartlinks-utils-ui/records-admin → RecordsAdminShell |
| Standalone rule editor | @proveanything/smartlinks-utils-ui/facet-rule-editor → FacetRuleEditor |
| Conditions editor (non-facet) | @proveanything/smartlinks-utils-ui/conditions-editor → ConditionsEditor |
| Best-match hook (React convenience wrapper) | @proveanything/smartlinks-utils-ui/records-admin → useResolvedRecord |
| All-matches hook (React convenience wrapper) | @proveanything/smartlinks-utils-ui/records-admin → useCollectedRecords |
| Multi-type hook (React convenience wrapper) | @proveanything/smartlinks-utils-ui/records-admin → useResolveAllRecords |
| Rule preview hook | @proveanything/smartlinks-utils-ui/records-admin → useRulePreview |
| Admin record CRUD (admin path) | @proveanything/smartlinks → SL.app.records.{upsert, bulkUpsert, bulkDelete, restore, previewRule} |
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.