@proveanything/smartlinks 1.10.0 → 1.10.2
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/api/appObjects.d.ts +16 -1
- package/dist/api/appObjects.js +23 -0
- package/dist/docs/API_SUMMARY.md +160 -49
- package/dist/docs/app-objects.md +160 -52
- package/dist/openapi.yaml +303 -38
- package/dist/types/appObjects.d.ts +193 -70
- package/docs/API_SUMMARY.md +160 -49
- package/docs/app-objects.md +160 -52
- package/openapi.yaml +303 -38
- package/package.json +1 -1
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.10.
|
|
3
|
+
Version: 1.10.2 | Generated: 2026-04-27T09:32:45.351Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -1880,24 +1880,24 @@ interface ReplyInput {
|
|
|
1880
1880
|
}
|
|
1881
1881
|
```
|
|
1882
1882
|
|
|
1883
|
-
**
|
|
1883
|
+
**FacetRuleClause** (interface)
|
|
1884
1884
|
```typescript
|
|
1885
|
-
interface
|
|
1886
|
-
key
|
|
1887
|
-
|
|
1885
|
+
interface FacetRuleClause {
|
|
1886
|
+
* Facet key this clause tests, e.g. "brand", "type", "bread-type".
|
|
1887
|
+
* Must reference a defined facet on the collection.
|
|
1888
|
+
facetKey: string
|
|
1889
|
+
* One or more facet value keys that satisfy the clause (OR semantics).
|
|
1890
|
+
* At least one value required. Server deduplicates and sorts.
|
|
1891
|
+
anyOf: string[]
|
|
1888
1892
|
}
|
|
1889
1893
|
```
|
|
1890
1894
|
|
|
1891
|
-
**
|
|
1895
|
+
**FacetRule** (interface)
|
|
1892
1896
|
```typescript
|
|
1893
|
-
interface
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
batchId?: string
|
|
1898
|
-
* Arbitrary facet clauses.
|
|
1899
|
-
* Clauses are ANDed together; valueKeys within a clause are ORed.
|
|
1900
|
-
facets?: ScopeFacetClause[]
|
|
1897
|
+
interface FacetRule {
|
|
1898
|
+
* All clauses must be satisfied (AND semantics).
|
|
1899
|
+
* Must be non-empty; no duplicate facetKey entries.
|
|
1900
|
+
all: FacetRuleClause[]
|
|
1901
1901
|
}
|
|
1902
1902
|
```
|
|
1903
1903
|
|
|
@@ -1908,8 +1908,10 @@ interface RecordTarget {
|
|
|
1908
1908
|
variantId?: string
|
|
1909
1909
|
proofId?: string
|
|
1910
1910
|
batchId?: string
|
|
1911
|
-
* Facet
|
|
1912
|
-
*
|
|
1911
|
+
* Facet assignments for the product (e.g. `{ brand: ['samsung'], type: ['tv'] }`).
|
|
1912
|
+
* Used exclusively to match FacetRule records via GIN-indexed containment check.
|
|
1913
|
+
* Does NOT filter legacy scope.facets arrays (that system is removed in SDK 1.12).
|
|
1914
|
+
* Omit to exclude rule records from results.
|
|
1913
1915
|
facets?: Record<string, string[]>
|
|
1914
1916
|
}
|
|
1915
1917
|
```
|
|
@@ -1919,14 +1921,18 @@ interface RecordTarget {
|
|
|
1919
1921
|
interface BulkUpsertItem {
|
|
1920
1922
|
ref: string
|
|
1921
1923
|
recordType?: string
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
+
productId?: string | null
|
|
1925
|
+
variantId?: string | null
|
|
1926
|
+
batchId?: string | null
|
|
1927
|
+
proofId?: string | null
|
|
1928
|
+
customId?: string | null
|
|
1929
|
+
sourceSystem?: string | null
|
|
1924
1930
|
startsAt?: string | null
|
|
1925
1931
|
expiresAt?: string | null
|
|
1926
1932
|
status?: string | null
|
|
1927
|
-
scope?: RecordScope
|
|
1928
1933
|
data?: Record<string, unknown> | null
|
|
1929
1934
|
metadata?: Record<string, unknown> | null
|
|
1935
|
+
facetRule?: FacetRule | null
|
|
1930
1936
|
}
|
|
1931
1937
|
```
|
|
1932
1938
|
|
|
@@ -1953,10 +1959,9 @@ interface BulkDeleteResult {
|
|
|
1953
1959
|
**MatchResult** (interface)
|
|
1954
1960
|
```typescript
|
|
1955
1961
|
interface MatchResult {
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
best?: Record<string, MatchedRecord>
|
|
1962
|
+
data: MatchEntry[]
|
|
1963
|
+
total: number
|
|
1964
|
+
strategy: 'all' | 'best'
|
|
1960
1965
|
}
|
|
1961
1966
|
```
|
|
1962
1967
|
|
|
@@ -1965,14 +1970,18 @@ interface MatchResult {
|
|
|
1965
1970
|
interface UpsertRecordInput {
|
|
1966
1971
|
ref: string
|
|
1967
1972
|
recordType?: string
|
|
1968
|
-
|
|
1969
|
-
|
|
1973
|
+
productId?: string | null
|
|
1974
|
+
variantId?: string | null
|
|
1975
|
+
batchId?: string | null
|
|
1976
|
+
proofId?: string | null
|
|
1977
|
+
customId?: string | null
|
|
1978
|
+
sourceSystem?: string | null
|
|
1970
1979
|
startsAt?: string | null
|
|
1971
1980
|
expiresAt?: string | null
|
|
1972
1981
|
status?: string | null
|
|
1973
|
-
scope?: RecordScope
|
|
1974
1982
|
data?: Record<string, unknown> | null
|
|
1975
1983
|
metadata?: Record<string, unknown> | null
|
|
1984
|
+
facetRule?: FacetRule | null
|
|
1976
1985
|
}
|
|
1977
1986
|
```
|
|
1978
1987
|
|
|
@@ -2001,10 +2010,15 @@ interface AppRecord {
|
|
|
2001
2010
|
visibility: Visibility
|
|
2002
2011
|
recordType: string | null
|
|
2003
2012
|
ref: string | null
|
|
2013
|
+
scopeType: string | null
|
|
2014
|
+
scopeId: string | null
|
|
2004
2015
|
customId: string | null
|
|
2016
|
+
customIdNormalized: string | null
|
|
2005
2017
|
sourceSystem: string | null
|
|
2006
2018
|
status: string | null
|
|
2007
2019
|
productId: string | null
|
|
2020
|
+
variantId: string | null
|
|
2021
|
+
batchId: string | null
|
|
2008
2022
|
proofId: string | null
|
|
2009
2023
|
contactId: string | null
|
|
2010
2024
|
authorId: string | null
|
|
@@ -2016,12 +2030,13 @@ interface AppRecord {
|
|
|
2016
2030
|
startsAt: string | null
|
|
2017
2031
|
expiresAt: string | null
|
|
2018
2032
|
deletedAt: string | null // admin only
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
2021
|
-
scope: RecordScope
|
|
2022
|
-
* Numeric specificity score computed from scope.
|
|
2023
|
-
* Higher = more specific. 0 = universal scope.
|
|
2033
|
+
* Numeric specificity score. Server-computed from anchor IDs and facetRule.
|
|
2034
|
+
* Higher = more specific. 0 = universal (no anchors, no rule).
|
|
2024
2035
|
specificity: number
|
|
2036
|
+
* Facet rule for rule records (ref starts with "rule:").
|
|
2037
|
+
* null on all other record types. Mutually exclusive with anchor IDs.
|
|
2038
|
+
facetRule: FacetRule | null
|
|
2039
|
+
singletonKey: string | null
|
|
2025
2040
|
data: Record<string, unknown>
|
|
2026
2041
|
owner: Record<string, unknown>
|
|
2027
2042
|
admin: Record<string, unknown> // admin only
|
|
@@ -2032,26 +2047,33 @@ interface AppRecord {
|
|
|
2032
2047
|
**CreateRecordInput** (interface)
|
|
2033
2048
|
```typescript
|
|
2034
2049
|
interface CreateRecordInput {
|
|
2035
|
-
recordType
|
|
2036
|
-
visibility?: Visibility
|
|
2037
|
-
ref?: string
|
|
2038
|
-
status?: string
|
|
2039
|
-
productId?: string
|
|
2040
|
-
|
|
2050
|
+
recordType?: string
|
|
2051
|
+
visibility?: Visibility
|
|
2052
|
+
ref?: string
|
|
2053
|
+
status?: string
|
|
2054
|
+
productId?: string | null
|
|
2055
|
+
variantId?: string | null
|
|
2056
|
+
batchId?: string | null
|
|
2057
|
+
proofId?: string | null
|
|
2041
2058
|
contactId?: string
|
|
2042
2059
|
authorId?: string
|
|
2043
2060
|
authorType?: string
|
|
2044
2061
|
parentType?: string
|
|
2045
2062
|
parentId?: string
|
|
2046
|
-
startsAt?: string
|
|
2047
|
-
expiresAt?: string
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2063
|
+
startsAt?: string | null
|
|
2064
|
+
expiresAt?: string | null
|
|
2065
|
+
scopeType?: string | null
|
|
2066
|
+
scopeId?: string | null
|
|
2067
|
+
customId?: string | null
|
|
2068
|
+
sourceSystem?: string | null
|
|
2069
|
+
* Opt-in singleton cardinality. When set, the server upserts rather than
|
|
2070
|
+
* inserting a duplicate. Values: 'collection' | 'product' | 'variant' | 'batch' | 'proof'
|
|
2071
|
+
singletonPer?: string
|
|
2051
2072
|
data?: Record<string, unknown>
|
|
2052
2073
|
owner?: Record<string, unknown>
|
|
2053
|
-
admin?: Record<string, unknown>
|
|
2074
|
+
admin?: Record<string, unknown>
|
|
2054
2075
|
metadata?: Record<string, unknown>
|
|
2076
|
+
facetRule?: FacetRule | null
|
|
2055
2077
|
}
|
|
2056
2078
|
```
|
|
2057
2079
|
|
|
@@ -2065,12 +2087,90 @@ interface UpdateRecordInput {
|
|
|
2065
2087
|
visibility?: Visibility
|
|
2066
2088
|
ref?: string
|
|
2067
2089
|
recordType?: string
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2090
|
+
productId?: string | null
|
|
2091
|
+
variantId?: string | null
|
|
2092
|
+
batchId?: string | null
|
|
2093
|
+
proofId?: string | null
|
|
2094
|
+
startsAt?: string | null
|
|
2095
|
+
expiresAt?: string | null
|
|
2096
|
+
scopeType?: string | null
|
|
2097
|
+
scopeId?: string | null
|
|
2098
|
+
customId?: string | null
|
|
2099
|
+
sourceSystem?: string | null
|
|
2073
2100
|
metadata?: Record<string, unknown>
|
|
2101
|
+
facetRule?: FacetRule | null
|
|
2102
|
+
}
|
|
2103
|
+
```
|
|
2104
|
+
|
|
2105
|
+
**ResolveAllParams** (interface)
|
|
2106
|
+
```typescript
|
|
2107
|
+
interface ResolveAllParams {
|
|
2108
|
+
context: {
|
|
2109
|
+
productId?: string
|
|
2110
|
+
variantId?: string
|
|
2111
|
+
batchId?: string
|
|
2112
|
+
proofId?: string
|
|
2113
|
+
* Facet assignments for the product — used for both legacy facet-ref matching
|
|
2114
|
+
* and facetRule evaluation.
|
|
2115
|
+
* e.g. { "brand": "samsung", "type": ["tv", "laptop"] }
|
|
2116
|
+
facets?: Record<string, string | string[]>
|
|
2117
|
+
}
|
|
2118
|
+
recordType?: string
|
|
2119
|
+
tiers?: Array<'proof' | 'batch' | 'variant' | 'product' | 'rule' | 'facet' | 'collection'>
|
|
2120
|
+
limit?: number
|
|
2121
|
+
at?: string
|
|
2122
|
+
includeScheduled?: boolean
|
|
2123
|
+
includeExpired?: boolean
|
|
2124
|
+
}
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
**ResolveAllResult** (interface)
|
|
2128
|
+
```typescript
|
|
2129
|
+
interface ResolveAllResult {
|
|
2130
|
+
records: ResolveAllEntry[]
|
|
2131
|
+
total: number
|
|
2132
|
+
context: ResolveAllContext
|
|
2133
|
+
truncated: boolean
|
|
2134
|
+
}
|
|
2135
|
+
```
|
|
2136
|
+
|
|
2137
|
+
**ResolveAllEntry** (interface)
|
|
2138
|
+
```typescript
|
|
2139
|
+
interface ResolveAllEntry {
|
|
2140
|
+
record: AppRecord
|
|
2141
|
+
matchedAt: MatchedAt
|
|
2142
|
+
specificity: number
|
|
2143
|
+
matchedRule?: FacetRule
|
|
2144
|
+
matchedClauseCount?: number
|
|
2145
|
+
}
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
**ResolveAllContext** (interface)
|
|
2149
|
+
```typescript
|
|
2150
|
+
interface ResolveAllContext {
|
|
2151
|
+
productId?: string
|
|
2152
|
+
variantId?: string
|
|
2153
|
+
batchId?: string
|
|
2154
|
+
proofId?: string
|
|
2155
|
+
facets?: Record<string, string[]>
|
|
2156
|
+
}
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
**PreviewRuleParams** (interface)
|
|
2160
|
+
```typescript
|
|
2161
|
+
interface PreviewRuleParams {
|
|
2162
|
+
facetRule: FacetRule
|
|
2163
|
+
recordType?: string
|
|
2164
|
+
limit?: number
|
|
2165
|
+
}
|
|
2166
|
+
```
|
|
2167
|
+
|
|
2168
|
+
**PreviewRuleResult** (interface)
|
|
2169
|
+
```typescript
|
|
2170
|
+
interface PreviewRuleResult {
|
|
2171
|
+
matchingProducts: Array<{ productId: string; name?: string; facets: Record<string, string[]> }>
|
|
2172
|
+
total: number
|
|
2173
|
+
rule: FacetRule
|
|
2074
2174
|
}
|
|
2075
2175
|
```
|
|
2076
2176
|
|
|
@@ -2140,7 +2240,7 @@ interface PublicCreateBranch {
|
|
|
2140
2240
|
|
|
2141
2241
|
**BulkDeleteInput** = ``
|
|
2142
2242
|
|
|
2143
|
-
**
|
|
2243
|
+
**MatchedAt** = ``
|
|
2144
2244
|
|
|
2145
2245
|
### asset
|
|
2146
2246
|
|
|
@@ -7306,6 +7406,17 @@ Upsert up to 500 records in a single transaction. Each row is individually error
|
|
|
7306
7406
|
input: BulkDeleteInput) → `Promise<BulkDeleteResult>`
|
|
7307
7407
|
Soft-delete records in bulk. Supports two modes: - **refs mode**: explicit list of refs (max 1000) - **scope mode**: delete by scope anchor (productId / variantId / etc.) POST /records/bulk-delete (admin only) ```ts // Refs mode await app.records.bulkDelete(collectionId, appId, { refs: ['product:prod_abc', 'product:prod_xyz'], recordType: 'nutrition', }); // Scope mode await app.records.bulkDelete(collectionId, appId, { scope: { productId: 'prod_abc' }, }); ```
|
|
7308
7408
|
|
|
7409
|
+
**resolveAll**(collectionId: string,
|
|
7410
|
+
appId: string,
|
|
7411
|
+
input: ResolveAllParams,
|
|
7412
|
+
admin: boolean = false) → `Promise<ResolveAllResult>`
|
|
7413
|
+
Resolve every applicable record for a product context in one call. Returns records across all tiers (proof, batch, variant, product, rule, facet, collection) deduplicated and sorted by specificity descending. POST /records/resolve-all
|
|
7414
|
+
|
|
7415
|
+
**previewRule**(collectionId: string,
|
|
7416
|
+
appId: string,
|
|
7417
|
+
input: PreviewRuleParams) → `Promise<PreviewRuleResult>`
|
|
7418
|
+
Preview which products in the collection match a given facetRule. Admin only. Use for live "matches N products" feedback while authoring a rule. POST /records/preview-rule
|
|
7419
|
+
|
|
7309
7420
|
### app.threads
|
|
7310
7421
|
|
|
7311
7422
|
Conversation-oriented app objects for comments, discussions, Q&A, and reply-driven experiences.
|
package/docs/app-objects.md
CHANGED
|
@@ -432,7 +432,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
432
432
|
|
|
433
433
|
## Records
|
|
434
434
|
|
|
435
|
-
**Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas. Records also support **structured
|
|
435
|
+
**Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas. Records also support **structured targeting** — each record can declare which products, variants, batches, or proofs it applies to (via anchor fields), or which product attributes match (via `facetRule`), and the platform can match records against a runtime context.
|
|
436
436
|
|
|
437
437
|
### When to Use Records
|
|
438
438
|
|
|
@@ -450,64 +450,102 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
450
450
|
|
|
451
451
|
- **Record types** — `recordType` field for categorization (required)
|
|
452
452
|
- **Time windows** — `startsAt` and `expiresAt` for time-based data
|
|
453
|
-
- **
|
|
454
|
-
- **
|
|
453
|
+
- **Anchor fields** — `productId`, `variantId`, `batchId`, `proofId` restrict which context the record applies to
|
|
454
|
+
- **Facet rules** — `facetRule` matches records to products based on attribute values
|
|
455
|
+
- **Specificity scoring** — `specificity` enables "best match" resolution across multiple targeted records
|
|
455
456
|
- **Parent linking** — attach to products, proofs, contacts, etc.
|
|
456
457
|
- **Author tracking** — `authorId` + `authorType`
|
|
457
458
|
- **Status lifecycle** — custom statuses (default `'active'`)
|
|
458
|
-
- **References** — optional `ref` field for external IDs; auto-derived from
|
|
459
|
+
- **References** — optional `ref` field for external IDs; auto-derived from anchor fields if omitted
|
|
459
460
|
|
|
460
|
-
###
|
|
461
|
+
### Targeted Records
|
|
461
462
|
|
|
462
|
-
|
|
463
|
+
Records use flat anchor fields to declare what context they apply to. A record with no anchor fields is universal — it applies everywhere. Populated anchor fields restrict the context to a specific product, variant, batch, or proof.
|
|
463
464
|
|
|
464
465
|
```typescript
|
|
465
466
|
import { app } from '@proveanything/smartlinks';
|
|
466
467
|
|
|
467
|
-
// A nutrition record
|
|
468
|
+
// A nutrition record anchored to a specific product
|
|
468
469
|
await app.records.create(collectionId, appId, {
|
|
469
470
|
recordType: 'nutrition',
|
|
470
|
-
|
|
471
|
+
productId: 'prod_abc',
|
|
471
472
|
data: { calories: 250, protein: 12.5 },
|
|
472
473
|
}, true);
|
|
473
474
|
|
|
474
475
|
// A nutrition record for a specific variant, overriding the product-level record
|
|
475
476
|
await app.records.create(collectionId, appId, {
|
|
476
477
|
recordType: 'nutrition',
|
|
477
|
-
|
|
478
|
+
productId: 'prod_abc',
|
|
479
|
+
variantId: 'var_500ml',
|
|
478
480
|
data: { calories: 260, protein: 12.5 },
|
|
479
481
|
}, true);
|
|
480
482
|
|
|
481
|
-
// A record
|
|
483
|
+
// A record matching products by facet rule (applies to all products with tier=gold)
|
|
482
484
|
await app.records.create(collectionId, appId, {
|
|
483
485
|
recordType: 'loyalty_promo',
|
|
484
|
-
|
|
485
|
-
|
|
486
|
+
facetRule: {
|
|
487
|
+
all: [{ facetKey: 'tier', anyOf: ['gold', 'platinum'] }],
|
|
486
488
|
},
|
|
487
489
|
data: { discountPercent: 15 },
|
|
488
490
|
}, true);
|
|
489
491
|
```
|
|
490
492
|
|
|
491
|
-
The `ref` field is derived automatically
|
|
493
|
+
The `ref` field is derived automatically from anchor fields when omitted:
|
|
492
494
|
|
|
493
495
|
```
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
496
|
+
productId: 'prod_abc' → ref: 'product:prod_abc'
|
|
497
|
+
productId: 'prod_abc', variantId: 'var_x' → ref: 'product:prod_abc/variant:var_x'
|
|
498
|
+
(no anchor fields) → ref: '' (universal)
|
|
499
|
+
facetRule: { ... } → ref: 'rule:<ulid>'
|
|
497
500
|
```
|
|
498
501
|
|
|
499
502
|
#### Specificity scores
|
|
500
503
|
|
|
501
504
|
When multiple scoped records match a context, they are ordered by `specificity`. Higher = more specific:
|
|
502
505
|
|
|
503
|
-
|
|
|
504
|
-
|
|
506
|
+
| Field / element | Points |
|
|
507
|
+
|-----------------|--------|
|
|
505
508
|
| `proofId` | +1000 |
|
|
506
509
|
| `batchId` | +500 |
|
|
507
510
|
| `variantId` | +250 |
|
|
508
511
|
| `productId` | +100 |
|
|
509
|
-
| Per
|
|
510
|
-
| Per
|
|
512
|
+
| Per `facetRule` clause | +50 |
|
|
513
|
+
| Per `anyOf` value | +1 |
|
|
514
|
+
| No anchors / no rule | 0 |
|
|
515
|
+
|
|
516
|
+
### Singleton Cardinality
|
|
517
|
+
|
|
518
|
+
By default, `create` always inserts a new row — calling it twice produces two records with identical anchor fields. **Singleton cardinality** changes that: pass `singletonPer` on creation and the server will **upsert** instead, ensuring at most one record of a given `recordType` exists per scope boundary.
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
// Ensure only one active registration record per product per contact
|
|
522
|
+
await app.records.create(collectionId, appId, {
|
|
523
|
+
recordType: 'product_registration',
|
|
524
|
+
visibility: 'owner',
|
|
525
|
+
contactId: user.contactId,
|
|
526
|
+
productId: product.id,
|
|
527
|
+
singletonPer: 'product', // one per (appId + recordType + contactId + productId)
|
|
528
|
+
data: { registeredAt: new Date().toISOString() },
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`singletonPer` values and the scope they enforce:
|
|
533
|
+
|
|
534
|
+
| Value | De-duplicates across |
|
|
535
|
+
|-------|---------------------|
|
|
536
|
+
| `'collection'` | entire app (one record of this type per app) |
|
|
537
|
+
| `'product'` | `productId` |
|
|
538
|
+
| `'variant'` | `variantId` |
|
|
539
|
+
| `'batch'` | `batchId` |
|
|
540
|
+
| `'proof'` | `proofId` |
|
|
541
|
+
|
|
542
|
+
The server assigns a **`singletonKey`** to each record that is governed by this rule — an opaque, stable string that acts as the upsert key. If a record with the same key already exists the server updates it in place (clearing `deletedAt` if it was soft-deleted) and returns the existing `id`. `singletonKey` is read-only and exposed on `AppRecord` for debugging but has no meaning to clients.
|
|
543
|
+
|
|
544
|
+
**When to use `singletonPer`:**
|
|
545
|
+
- One loyalty card per contact per product
|
|
546
|
+
- One registration per proof scan
|
|
547
|
+
- One active subscription record per variant
|
|
548
|
+
- Any "find-or-create" pattern where calling `create` twice should be idempotent
|
|
511
549
|
|
|
512
550
|
### Matching Records Against a Context
|
|
513
551
|
|
|
@@ -515,22 +553,22 @@ Use `app.records.match()` to find records whose scope is satisfied by a runtime
|
|
|
515
553
|
|
|
516
554
|
```typescript
|
|
517
555
|
// Find all nutrition records that apply for this product + facet context
|
|
518
|
-
const {
|
|
556
|
+
const { data } = await app.records.match(collectionId, appId, {
|
|
519
557
|
target: {
|
|
520
558
|
productId: 'prod_abc',
|
|
521
559
|
facets: { tier: ['gold'] },
|
|
522
560
|
},
|
|
523
561
|
recordType: 'nutrition',
|
|
524
562
|
}, true);
|
|
525
|
-
//
|
|
526
|
-
// each
|
|
563
|
+
// data is ordered by specificity descending — most specific first
|
|
564
|
+
// each entry carries a `matchedAt` field indicating which dimension matched
|
|
527
565
|
|
|
528
|
-
//
|
|
529
|
-
const { best } = await app.records.match(collectionId, appId, {
|
|
566
|
+
// Use strategy: 'best' to get only the single winner per recordType
|
|
567
|
+
const { data: best } = await app.records.match(collectionId, appId, {
|
|
530
568
|
target: { productId: 'prod_abc', variantId: 'var_500ml' },
|
|
531
569
|
strategy: 'best',
|
|
532
570
|
}, true);
|
|
533
|
-
// best
|
|
571
|
+
// best[0] → the highest-specificity record for this context
|
|
534
572
|
```
|
|
535
573
|
|
|
536
574
|
Facet matching rules:
|
|
@@ -540,29 +578,99 @@ Facet matching rules:
|
|
|
540
578
|
|
|
541
579
|
#### `matchedAt` — match attribution
|
|
542
580
|
|
|
543
|
-
Every record in the response includes a `matchedAt` field indicating **which
|
|
581
|
+
Every record in the response includes a `matchedAt` field indicating **which matching dimension caused the match**. Use it to render attribution labels:
|
|
544
582
|
|
|
545
583
|
```typescript
|
|
546
|
-
const {
|
|
547
|
-
|
|
548
|
-
for (const
|
|
549
|
-
switch (
|
|
550
|
-
case '
|
|
551
|
-
case '
|
|
552
|
-
case '
|
|
553
|
-
case '
|
|
554
|
-
case '
|
|
555
|
-
case '
|
|
584
|
+
const { data } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true);
|
|
585
|
+
|
|
586
|
+
for (const entry of data) {
|
|
587
|
+
switch (entry.matchedAt) {
|
|
588
|
+
case 'rule': /* "Matches rule" */ break;
|
|
589
|
+
case 'proof': /* "Scan-specific" */ break;
|
|
590
|
+
case 'batch': /* "Batch-specific" */ break;
|
|
591
|
+
case 'variant': /* "Size-specific" */ break;
|
|
592
|
+
case 'product': /* "Inherited from product" */ break;
|
|
593
|
+
case 'facet': /* "Tier-specific" */ break;
|
|
594
|
+
case 'collection': /* "Collection default" */ break;
|
|
595
|
+
case 'universal': /* "Default" */ break;
|
|
556
596
|
}
|
|
557
597
|
}
|
|
558
598
|
```
|
|
559
599
|
|
|
560
|
-
Precedence follows
|
|
600
|
+
Precedence follows: `rule > proof > batch > variant > product > facet > collection > universal`.
|
|
561
601
|
|
|
562
602
|
#### React — `useResolvedRecord`
|
|
563
603
|
|
|
564
604
|
For React consumers, the `useResolvedRecord` hook in `@proveanything/smartlinks-utils-ui` wraps `records.match()` and returns the best-matching record with loading and error states. The raw `records.match()` API exists for non-React consumers and custom resolution logic.
|
|
565
605
|
|
|
606
|
+
### Facet-Rule Records
|
|
607
|
+
|
|
608
|
+
A record can declare a **multi-clause boolean rule** (`facetRule`) describing which products it applies to, instead of a single `scope.facets` entry. The rule is AND across facet keys, OR within values of each key:
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
// Create a record that matches all Samsung TVs and laptops
|
|
612
|
+
await app.records.create(collectionId, appId, {
|
|
613
|
+
recordType: 'warranty',
|
|
614
|
+
facetRule: {
|
|
615
|
+
all: [
|
|
616
|
+
{ facetKey: 'brand', anyOf: ['samsung'] },
|
|
617
|
+
{ facetKey: 'type', anyOf: ['tv', 'laptop'] },
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
data: { warrantyYears: 2 },
|
|
621
|
+
}, true);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`facetRule` and anchor fields (`productId`, `variantId`, etc.) are mutually exclusive. A record uses either anchor-based targeting or a facetRule, never both. The server assigns `ref: 'rule:<ulid>'` automatically.
|
|
625
|
+
|
|
626
|
+
Specificity for rule records: `Σ (50 + clause.anyOf.length)` across all clauses. A 2-clause rule with 1 value each scores `(50+1)+(50+1) = 102`, which ranks above a plain product-scoped record (100) in `resolveAll()` results.
|
|
627
|
+
|
|
628
|
+
Use `records.previewRule()` to see which products a rule would match before creating it:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
const { matchingProducts, total } = await app.records.previewRule(collectionId, appId, {
|
|
632
|
+
facetRule: {
|
|
633
|
+
all: [{ facetKey: 'brand', anyOf: ['samsung'] }],
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
// total: 42, matchingProducts: [{ productId: 'prod_001', facets: {...} }, ...]
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Resolve All
|
|
640
|
+
|
|
641
|
+
Use `app.records.resolveAll()` to fetch **every applicable record for a product context** in one request—across all tiers (proof, batch, variant, product, rule, facet, collection defaults), deduplicated and sorted by specificity:
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
// All records that apply to this product context (admin)
|
|
645
|
+
const { records, total, truncated } = await app.records.resolveAll(collectionId, appId, {
|
|
646
|
+
context: {
|
|
647
|
+
productId: 'prod_001',
|
|
648
|
+
facets: { brand: 'samsung', type: 'tv' },
|
|
649
|
+
},
|
|
650
|
+
recordType: 'warranty',
|
|
651
|
+
}, true);
|
|
652
|
+
|
|
653
|
+
for (const entry of records) {
|
|
654
|
+
console.log(entry.matchedAt, entry.specificity, entry.record.id);
|
|
655
|
+
if (entry.matchedAt === 'rule') {
|
|
656
|
+
console.log('rule fired:', entry.matchedRule, 'clauses:', entry.matchedClauseCount);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Public endpoint — visibility-filtered (admin records excluded)
|
|
661
|
+
const { records: publicRecords } = await app.records.resolveAll(collectionId, appId, {
|
|
662
|
+
context: { productId: 'prod_001', facets: { brand: 'samsung' } },
|
|
663
|
+
}, false);
|
|
664
|
+
|
|
665
|
+
// Filter to specific tiers
|
|
666
|
+
const { records: ruleRecords } = await app.records.resolveAll(collectionId, appId, {
|
|
667
|
+
context: { productId: 'prod_001', facets: { brand: 'samsung', type: 'tv' } },
|
|
668
|
+
tiers: ['product', 'rule', 'collection'],
|
|
669
|
+
}, true);
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`truncated: true` means the result hit the safety cap (default 500). Raise it with `limit` (max 5000).
|
|
673
|
+
|
|
566
674
|
### Upsert
|
|
567
675
|
|
|
568
676
|
Create-or-update a record by `ref` in a single call:
|
|
@@ -571,7 +679,7 @@ Create-or-update a record by `ref` in a single call:
|
|
|
571
679
|
const { created } = await app.records.upsert(collectionId, appId, {
|
|
572
680
|
ref: 'product:prod_abc',
|
|
573
681
|
recordType: 'nutrition',
|
|
574
|
-
|
|
682
|
+
productId: 'prod_abc',
|
|
575
683
|
data: { calories: 250, protein: 12.5 },
|
|
576
684
|
});
|
|
577
685
|
// created: true if new, false if updated
|
|
@@ -584,8 +692,8 @@ Upsert or delete large sets of records efficiently:
|
|
|
584
692
|
```typescript
|
|
585
693
|
// Bulk upsert up to 500 records in one transaction
|
|
586
694
|
const result = await app.records.bulkUpsert(collectionId, appId, [
|
|
587
|
-
{ ref: 'product:prod_abc', recordType: 'nutrition',
|
|
588
|
-
{ ref: 'product:prod_xyz', recordType: 'nutrition',
|
|
695
|
+
{ ref: 'product:prod_abc', recordType: 'nutrition', productId: 'prod_abc', data: { calories: 250 } },
|
|
696
|
+
{ ref: 'product:prod_xyz', recordType: 'nutrition', productId: 'prod_xyz', data: { calories: 180 } },
|
|
589
697
|
]);
|
|
590
698
|
// result: { saved: 2, failed: 0, results: [...] }
|
|
591
699
|
|
|
@@ -595,7 +703,7 @@ await app.records.bulkDelete(collectionId, appId, {
|
|
|
595
703
|
recordType: 'nutrition',
|
|
596
704
|
});
|
|
597
705
|
|
|
598
|
-
// Bulk delete by
|
|
706
|
+
// Bulk delete by anchor (all records under a product)
|
|
599
707
|
await app.records.bulkDelete(collectionId, appId, {
|
|
600
708
|
scope: { productId: 'prod_abc' },
|
|
601
709
|
});
|
|
@@ -649,7 +757,7 @@ await app.records.upsert(collectionId, appId, {
|
|
|
649
757
|
customId: slug,
|
|
650
758
|
sourceSystem: 'contentful',
|
|
651
759
|
recordType: 'content_page',
|
|
652
|
-
|
|
760
|
+
productId,
|
|
653
761
|
data: { title, body },
|
|
654
762
|
});
|
|
655
763
|
// upsert finds-or-creates by ref deterministically,
|
|
@@ -684,21 +792,21 @@ await app.records.aggregate(collectionId, appId, {
|
|
|
684
792
|
|
|
685
793
|
### Canonical Ref Format
|
|
686
794
|
|
|
687
|
-
The `ref` field is **server-derived** when
|
|
795
|
+
The `ref` field is **server-derived** when anchor fields are provided and `ref` is omitted. Clients should never construct ref strings manually. The authoritative grammar is slash-joined:
|
|
688
796
|
|
|
689
797
|
```
|
|
690
|
-
[
|
|
798
|
+
[product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
|
|
691
799
|
```
|
|
692
800
|
|
|
693
801
|
Examples:
|
|
694
802
|
|
|
695
|
-
|
|
|
696
|
-
|
|
697
|
-
| `
|
|
698
|
-
| `
|
|
699
|
-
| `
|
|
700
|
-
| `
|
|
701
|
-
|
|
|
803
|
+
| Anchor fields | Derived ref |
|
|
804
|
+
|---------------|-------------|
|
|
805
|
+
| `productId: 'prod_abc'` | `product:prod_abc` |
|
|
806
|
+
| `productId: 'prod_abc', variantId: 'var_500ml'` | `product:prod_abc/variant:var_500ml` |
|
|
807
|
+
| `batchId: 'batch_q1'` | `batch:batch_q1` |
|
|
808
|
+
| `facetRule: { ... }` | `rule:<ulid>` |
|
|
809
|
+
| *(no anchor fields)* | `''` (universal) |
|
|
702
810
|
|
|
703
811
|
`parseRef` / `buildRef` in `data/refs.ts` should be used for **display and URL round-tripping only**, never as upsert keys. For ETL use cases, set an explicit `ref` using a stable external key (see [External ID / ETL Workflow](#external-id--etl-workflow)).
|
|
704
812
|
|