@proveanything/smartlinks 1.9.23 → 1.10.1

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,4 +1,4 @@
1
- import type { AppCase, CreateCaseInput, UpdateCaseInput, AppendHistoryInput, CaseSummaryRequest, CaseSummaryResponse, CaseListQueryParams, AppThread, CreateThreadInput, UpdateThreadInput, ReplyInput, ThreadListQueryParams, AppRecord, CreateRecordInput, CreateRecordResponse, UpdateRecordInput, UpsertRecordInput, UpsertRecordResponse, MatchRecordsInput, MatchResult, BulkUpsertItem, BulkUpsertResult, BulkDeleteResult, RecordScope, RecordListQueryParams, PaginatedResponse, AggregateRequest, AggregateResponse, RelatedResponse } from '../types/appObjects';
1
+ import type { AppCase, CreateCaseInput, UpdateCaseInput, AppendHistoryInput, CaseSummaryRequest, CaseSummaryResponse, CaseListQueryParams, AppThread, CreateThreadInput, UpdateThreadInput, ReplyInput, ThreadListQueryParams, AppRecord, CreateRecordInput, CreateRecordResponse, UpdateRecordInput, UpsertRecordInput, UpsertRecordResponse, MatchRecordsInput, MatchResult, BulkUpsertItem, BulkUpsertResult, BulkDeleteResult, BulkDeleteInput, RecordListQueryParams, ResolveAllParams, ResolveAllResult, PreviewRuleParams, PreviewRuleResult, PaginatedResponse, AggregateRequest, AggregateResponse, RelatedResponse } from '../types/appObjects';
2
2
  export declare namespace app {
3
3
  namespace cases {
4
4
  /**
@@ -238,12 +238,21 @@ export declare namespace app {
238
238
  * });
239
239
  * ```
240
240
  */
241
- function bulkDelete(collectionId: string, appId: string, input: {
242
- refs: string[];
243
- recordType?: string;
244
- } | {
245
- scope: Omit<RecordScope, 'facets'>;
246
- recordType?: string;
247
- }): Promise<BulkDeleteResult>;
241
+ function bulkDelete(collectionId: string, appId: string, input: BulkDeleteInput): Promise<BulkDeleteResult>;
242
+ /**
243
+ * Resolve every applicable record for a product context in one call.
244
+ * Returns records across all tiers (proof, batch, variant, product, rule, facet, collection)
245
+ * deduplicated and sorted by specificity descending.
246
+ * POST /records/resolve-all
247
+ *
248
+ * @param admin - false for public (visibility-filtered), true for admin (all records)
249
+ */
250
+ function resolveAll(collectionId: string, appId: string, input: ResolveAllParams, admin?: boolean): Promise<ResolveAllResult>;
251
+ /**
252
+ * Preview which products in the collection match a given facetRule.
253
+ * Admin only. Use for live "matches N products" feedback while authoring a rule.
254
+ * POST /records/preview-rule
255
+ */
256
+ function previewRule(collectionId: string, appId: string, input: PreviewRuleParams): Promise<PreviewRuleResult>;
248
257
  }
249
258
  }
@@ -378,6 +378,29 @@ export var app;
378
378
  return post(path, input);
379
379
  }
380
380
  records_1.bulkDelete = bulkDelete;
381
+ /**
382
+ * Resolve every applicable record for a product context in one call.
383
+ * Returns records across all tiers (proof, batch, variant, product, rule, facet, collection)
384
+ * deduplicated and sorted by specificity descending.
385
+ * POST /records/resolve-all
386
+ *
387
+ * @param admin - false for public (visibility-filtered), true for admin (all records)
388
+ */
389
+ async function resolveAll(collectionId, appId, input, admin = false) {
390
+ const path = `${basePath(collectionId, appId, admin)}/resolve-all`;
391
+ return post(path, input);
392
+ }
393
+ records_1.resolveAll = resolveAll;
394
+ /**
395
+ * Preview which products in the collection match a given facetRule.
396
+ * Admin only. Use for live "matches N products" feedback while authoring a rule.
397
+ * POST /records/preview-rule
398
+ */
399
+ async function previewRule(collectionId, appId, input) {
400
+ const path = `${basePath(collectionId, appId, true)}/preview-rule`;
401
+ return post(path, input);
402
+ }
403
+ records_1.previewRule = previewRule;
381
404
  })(records = app.records || (app.records = {}));
382
405
  })(app || (app = {})); // end namespace app
383
406
  // ==================== HELPERS ====================
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.9.23 | Generated: 2026-04-25T12:36:26.919Z
3
+ Version: 1.10.1 | Generated: 2026-04-25T15:35:27.893Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -1880,6 +1880,27 @@ interface ReplyInput {
1880
1880
  }
1881
1881
  ```
1882
1882
 
1883
+ **FacetRuleClause** (interface)
1884
+ ```typescript
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[]
1892
+ }
1893
+ ```
1894
+
1895
+ **FacetRule** (interface)
1896
+ ```typescript
1897
+ interface FacetRule {
1898
+ * All clauses must be satisfied (AND semantics).
1899
+ * Must be non-empty; no duplicate facetKey entries.
1900
+ all: FacetRuleClause[]
1901
+ }
1902
+ ```
1903
+
1883
1904
  **ScopeFacetClause** (interface)
1884
1905
  ```typescript
1885
1906
  interface ScopeFacetClause {
@@ -1927,6 +1948,7 @@ interface BulkUpsertItem {
1927
1948
  scope?: RecordScope
1928
1949
  data?: Record<string, unknown> | null
1929
1950
  metadata?: Record<string, unknown> | null
1951
+ facetRule?: FacetRule | null
1930
1952
  }
1931
1953
  ```
1932
1954
 
@@ -1950,13 +1972,26 @@ interface BulkDeleteResult {
1950
1972
  }
1951
1973
  ```
1952
1974
 
1975
+ **MatchEntry** (interface)
1976
+ ```typescript
1977
+ interface MatchEntry {
1978
+ record: AppRecord
1979
+ matchedAt: MatchedAt
1980
+ matchedRule?: FacetRule
1981
+ * Number of clauses in the rule that fired.
1982
+ * Present only when matchedAt === 'rule'.
1983
+ matchedClauseCount?: number
1984
+ specificity: number
1985
+ }
1986
+ ```
1987
+
1953
1988
  **MatchResult** (interface)
1954
1989
  ```typescript
1955
1990
  interface MatchResult {
1956
- records: AppRecord[]
1991
+ records: MatchEntry[]
1957
1992
  * Only present when strategy is 'best'.
1958
1993
  * The single highest-specificity record per recordType.
1959
- best?: Record<string, AppRecord>
1994
+ best?: Record<string, MatchEntry>
1960
1995
  }
1961
1996
  ```
1962
1997
 
@@ -1973,6 +2008,7 @@ interface UpsertRecordInput {
1973
2008
  scope?: RecordScope
1974
2009
  data?: Record<string, unknown> | null
1975
2010
  metadata?: Record<string, unknown> | null
2011
+ facetRule?: FacetRule | null
1976
2012
  }
1977
2013
  ```
1978
2014
 
@@ -2004,8 +2040,8 @@ interface AppRecord {
2004
2040
  customId: string | null
2005
2041
  sourceSystem: string | null
2006
2042
  status: string | null
2007
- productId: string | null // @deprecated — use scope.productId
2008
- proofId: string | null // @deprecated — use scope.proofId
2043
+ productId: string | null
2044
+ proofId: string | null
2009
2045
  contactId: string | null
2010
2046
  authorId: string | null
2011
2047
  authorType: string
@@ -2022,6 +2058,10 @@ interface AppRecord {
2022
2058
  * Numeric specificity score computed from scope.
2023
2059
  * Higher = more specific. 0 = universal scope.
2024
2060
  specificity: number
2061
+ * Facet rule for rule records (ref starts with "rule:").
2062
+ * null on all other record types. Mutually exclusive with scope.
2063
+ * SDK 1.10.
2064
+ facetRule: FacetRule | null
2025
2065
  data: Record<string, unknown>
2026
2066
  owner: Record<string, unknown>
2027
2067
  admin: Record<string, unknown> // admin only
@@ -2052,6 +2092,7 @@ interface CreateRecordInput {
2052
2092
  owner?: Record<string, unknown>
2053
2093
  admin?: Record<string, unknown> // admin only
2054
2094
  metadata?: Record<string, unknown>
2095
+ facetRule?: FacetRule | null
2055
2096
  }
2056
2097
  ```
2057
2098
 
@@ -2071,6 +2112,57 @@ interface UpdateRecordInput {
2071
2112
  customId?: string
2072
2113
  sourceSystem?: string
2073
2114
  metadata?: Record<string, unknown>
2115
+ facetRule?: FacetRule | null
2116
+ }
2117
+ ```
2118
+
2119
+ **ResolveAllParams** (interface)
2120
+ ```typescript
2121
+ interface ResolveAllParams {
2122
+ context: {
2123
+ productId?: string
2124
+ variantId?: string
2125
+ batchId?: string
2126
+ proofId?: string
2127
+ * Facet assignments for the product — used for both legacy facet-ref matching
2128
+ * and facetRule evaluation.
2129
+ * e.g. { "brand": "samsung", "type": ["tv", "laptop"] }
2130
+ facets?: Record<string, string | string[]>
2131
+ }
2132
+ recordType?: string
2133
+ tiers?: Array<'proof' | 'batch' | 'variant' | 'product' | 'rule' | 'facet' | 'collection'>
2134
+ limit?: number
2135
+ at?: string
2136
+ includeScheduled?: boolean
2137
+ includeExpired?: boolean
2138
+ }
2139
+ ```
2140
+
2141
+ **ResolveAllResult** (interface)
2142
+ ```typescript
2143
+ interface ResolveAllResult {
2144
+ * Every applicable record for the given product context, sorted by precedence
2145
+ * (most-specific first). Each record appears at most once.
2146
+ records: MatchEntry[]
2147
+ * true if the result was truncated at the safety cap.
2148
+ * Default cap: 500 records. Use `limit` to raise it (max 5000).
2149
+ truncated?: boolean
2150
+ }
2151
+ ```
2152
+
2153
+ **PreviewRuleParams** (interface)
2154
+ ```typescript
2155
+ interface PreviewRuleParams {
2156
+ facetRule: FacetRule
2157
+ limit?: number
2158
+ }
2159
+ ```
2160
+
2161
+ **PreviewRuleResult** (interface)
2162
+ ```typescript
2163
+ interface PreviewRuleResult {
2164
+ sampleProductIds: string[]
2165
+ totalMatches: number
2074
2166
  }
2075
2167
  ```
2076
2168
 
@@ -2138,6 +2230,10 @@ interface PublicCreateBranch {
2138
2230
 
2139
2231
  **CallerRole** = `'admin' | 'owner' | 'public'`
2140
2232
 
2233
+ **BulkDeleteInput** = ``
2234
+
2235
+ **MatchedAt** = ``
2236
+
2141
2237
  ### asset
2142
2238
 
2143
2239
  **Asset** (interface)
@@ -7299,9 +7395,20 @@ Upsert up to 500 records in a single transaction. Each row is individually error
7299
7395
 
7300
7396
  **bulkDelete**(collectionId: string,
7301
7397
  appId: string,
7302
- input: { refs: string[]; recordType?: string } | { scope: Omit<RecordScope, 'facets'>; recordType?: string }) → `Promise<BulkDeleteResult>`
7398
+ input: BulkDeleteInput) → `Promise<BulkDeleteResult>`
7303
7399
  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' }, }); ```
7304
7400
 
7401
+ **resolveAll**(collectionId: string,
7402
+ appId: string,
7403
+ input: ResolveAllParams,
7404
+ admin: boolean = false) → `Promise<ResolveAllResult>`
7405
+ 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
7406
+
7407
+ **previewRule**(collectionId: string,
7408
+ appId: string,
7409
+ input: PreviewRuleParams) → `Promise<PreviewRuleResult>`
7410
+ 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
7411
+
7305
7412
  ### app.threads
7306
7413
 
7307
7414
  Conversation-oriented app objects for comments, discussions, Q&A, and reply-driven experiences.
@@ -523,6 +523,7 @@ const { records } = await app.records.match(collectionId, appId, {
523
523
  recordType: 'nutrition',
524
524
  }, true);
525
525
  // records is ordered by specificity descending — most specific first
526
+ // each record carries a `matchedAt` field indicating which scope dimension matched
526
527
 
527
528
  // Or use strategy: 'best' to get the single winner per recordType
528
529
  const { best } = await app.records.match(collectionId, appId, {
@@ -537,6 +538,101 @@ Facet matching rules:
537
538
  - Values within a single clause are **ORed** — any matching value satisfies it
538
539
  - A record with no facet clauses is satisfied by any target
539
540
 
541
+ #### `matchedAt` — match attribution
542
+
543
+ Every record in the response includes a `matchedAt` field indicating **which scope dimension caused the match**. Use it to render attribution labels without inspecting scope fields:
544
+
545
+ ```typescript
546
+ const { records } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true);
547
+
548
+ for (const entry of records) {
549
+ switch (entry.matchedAt) {
550
+ case 'proof': /* "Scan-specific" */ break;
551
+ case 'batch': /* "Batch-specific" */ break;
552
+ case 'variant': /* "Size-specific" */ break;
553
+ case 'product': /* "Inherited from product" */ break;
554
+ case 'rule': /* "Matches rule" */ break;
555
+ case 'facet': /* "Tier-specific" */ break;
556
+ case 'collection': /* "Collection default" */ break;
557
+ case 'universal': /* "Default" */ break;
558
+ }
559
+ }
560
+ ```
561
+
562
+ Precedence follows: `proof > batch > variant > product > rule > facet > collection > universal`.
563
+
564
+ #### React — `useResolvedRecord`
565
+
566
+ 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.
567
+
568
+ ### Facet-Rule Records
569
+
570
+ 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:
571
+
572
+ ```typescript
573
+ // Create a record that matches all Samsung TVs and laptops
574
+ await app.records.create(collectionId, appId, {
575
+ recordType: 'warranty',
576
+ facetRule: {
577
+ all: [
578
+ { facetKey: 'brand', anyOf: ['samsung'] },
579
+ { facetKey: 'type', anyOf: ['tv', 'laptop'] },
580
+ ],
581
+ },
582
+ data: { warrantyYears: 2 },
583
+ }, true);
584
+ ```
585
+
586
+ `facetRule` is **mutually exclusive with `scope`**. A record has either a structured scope or a facetRule, never both. The server assigns `ref: 'rule:<ulid>'` automatically.
587
+
588
+ 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.
589
+
590
+ Use `records.previewRule()` to see which products a rule would match before creating it:
591
+
592
+ ```typescript
593
+ const { sampleProductIds, totalMatches } = await app.records.previewRule(collectionId, appId, {
594
+ facetRule: {
595
+ all: [{ facetKey: 'brand', anyOf: ['samsung'] }],
596
+ },
597
+ });
598
+ // totalMatches: 42, sampleProductIds: ['prod_001', 'prod_002', ...]
599
+ ```
600
+
601
+ ### Resolve All
602
+
603
+ 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:
604
+
605
+ ```typescript
606
+ // All records that apply to this product context (admin)
607
+ const { records, truncated } = await app.records.resolveAll(collectionId, appId, {
608
+ context: {
609
+ productId: 'prod_001',
610
+ facets: { brand: 'samsung', type: 'tv' },
611
+ },
612
+ recordType: 'warranty',
613
+ }, true);
614
+
615
+ for (const entry of records) {
616
+ console.log(entry.matchedAt, entry.specificity, entry.record.id);
617
+ if (entry.matchedAt === 'rule') {
618
+ console.log('rule fired:', entry.matchedRule, 'clauses:', entry.matchedClauseCount);
619
+ }
620
+ }
621
+
622
+ // Public endpoint — visibility-filtered (admin records excluded)
623
+ const { records: publicRecords } = await app.records.resolveAll(collectionId, appId, {
624
+ context: { productId: 'prod_001', facets: { brand: 'samsung' } },
625
+ }, false);
626
+
627
+ // Filter to specific tiers
628
+ const { records: ruleRecords } = await app.records.resolveAll(collectionId, appId, {
629
+ context: { productId: 'prod_001', facets: { brand: 'samsung', type: 'tv' } },
630
+ tiers: ['product', 'rule', 'collection'],
631
+ }, true);
632
+ ```
633
+
634
+ `truncated: true` means the result hit the safety cap (default 500). Raise it with `limit` (max 5000).
635
+
540
636
  ### Upsert
541
637
 
542
638
  Create-or-update a record by `ref` in a single call:
@@ -575,13 +671,106 @@ await app.records.bulkDelete(collectionId, appId, {
575
671
  });
576
672
  ```
577
673
 
578
- ### Restore a Soft-Deleted Record
674
+ ### Soft-Delete Semantics
675
+
676
+ `delete` and `bulkDelete` **soft-delete** records: the row is retained with a non-null `deletedAt` and excluded from all queries by default. Records are **recoverable indefinitely** — there is no expiry on `deletedAt`.
579
677
 
580
678
  ```typescript
679
+ // Single-record restore
581
680
  const restored = await app.records.restore(collectionId, appId, recordId);
681
+
682
+ // List including deleted records (admin only)
683
+ const all = await app.records.list(collectionId, appId, {
684
+ recordType: 'nutrition',
685
+ includeDeleted: true,
686
+ }, true);
687
+ // all.data includes records with non-null deletedAt
688
+ ```
689
+
690
+ `bulkDelete` is fully reversible: rows survive with their IDs intact. Restore individually via `restore`, or re-write via `bulkUpsert` (which will find the existing row by `ref` and update it, clearing `deletedAt` in the process).
691
+
692
+ ### Text Search
693
+
694
+ The `q` parameter on `GET /records` performs a **case-insensitive substring match** (`ILIKE`) on `data->>'label'`. It works on both admin and public list endpoints today:
695
+
696
+ ```typescript
697
+ const results = await app.records.list(collectionId, appId, {
698
+ recordType: 'product',
699
+ q: 'premium',
700
+ }, true);
701
+ // returns records where data.label contains 'premium' (case-insensitive)
702
+ ```
703
+
704
+ > `q` is not a full-text index and does not return ranked results. For ranked relevance search over large corpora, use the Elasticsearch integration.
705
+
706
+ ### External ID / ETL Workflow
707
+
708
+ `customId` and `sourceSystem` provide a stable external key pair for loading records from external systems (CMS, ERP, PIM, etc.):
709
+
710
+ - Both fields are **indexed** via a composite index on `(sourceSystem, customIdNormalized)`.
711
+ - `customId` is **filterable** on `GET /records?customId=x&sourceSystem=y`.
712
+ - The pair is **not unique** — the same external ID can exist across different `recordType` values by design (a CMS slug can appear in both a `content` and a `nutrition` record).
713
+ - `upsert` currently keys on `ref`, not `customId`. The recommended ETL pattern is to derive a stable `ref` from the external ID and pass `customId` alongside:
714
+
715
+ ```typescript
716
+ // Idiomatic ETL upsert: ref is derived from the external key, customId carries it too
717
+ await app.records.upsert(collectionId, appId, {
718
+ ref: `cms:${slug}`, // stable find-or-create key
719
+ customId: slug,
720
+ sourceSystem: 'contentful',
721
+ recordType: 'content_page',
722
+ scope: { productId },
723
+ data: { title, body },
724
+ });
725
+ // upsert finds-or-creates by ref deterministically,
726
+ // customId is stored for later reverse-lookup via ?customId=&sourceSystem=
727
+ ```
728
+
729
+ ### Counts by Record Type
730
+
731
+ `aggregate()` returns counts grouped by `record_type` in a single round-trip — no separate endpoint needed:
732
+
733
+ ```typescript
734
+ const stats = await app.records.aggregate(collectionId, appId, {
735
+ groupBy: ['record_type'],
736
+ metrics: ['count'],
737
+ // Optionally narrow the corpus:
738
+ filters: { status: 'active' },
739
+ }, true);
740
+ // stats.groups → [{ record_type: 'nutrition', count: 42 }, { record_type: 'loyalty_promo', count: 7 }, ...]
741
+ // Ordered by count descending
582
742
  ```
583
743
 
584
- ### Scheduling Filters
744
+ You can also combine with other filters:
745
+
746
+ ```typescript
747
+ // Counts per type for a specific product
748
+ await app.records.aggregate(collectionId, appId, {
749
+ groupBy: ['record_type'],
750
+ metrics: ['count'],
751
+ filters: { product_id: 'prod_abc' },
752
+ }, true);
753
+ ```
754
+
755
+ ### Canonical Ref Format
756
+
757
+ The `ref` field is **server-derived** when you provide a `scope` and omit `ref`. Clients should never construct ref strings manually. The authoritative grammar is slash-joined:
758
+
759
+ ```
760
+ [facet:{key}={val1}+{val2}/][product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
761
+ ```
762
+
763
+ Examples:
764
+
765
+ | Scope | Derived ref |
766
+ |-------|-------------|
767
+ | `{ productId: 'prod_abc' }` | `product:prod_abc` |
768
+ | `{ productId: 'prod_abc', variantId: 'var_500ml' }` | `product:prod_abc/variant:var_500ml` |
769
+ | `{ batchId: 'batch_q1' }` | `batch:batch_q1` |
770
+ | `{ facets: [{ key: 'tier', valueKeys: ['gold'] }] }` | `facet:tier=gold` |
771
+ | `{}` | `''` (universal) |
772
+
773
+ `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)).
585
774
 
586
775
  `startsAt` and `expiresAt` control record active windows. The list and match endpoints respect scheduling by default (only returning currently-active records). Override with:
587
776
 
@@ -30,7 +30,7 @@ Without a shared pattern, every app reinvents:
30
30
  - CSV import/export
31
31
  - bulk operations
32
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/ui-utils` (see the [companion guide](ui-utils.md)) can stay simple.
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
34
 
35
35
  ---
36
36
 
@@ -71,20 +71,35 @@ variant:<productId>:<variantId>
71
71
  batch:<productId>:<batchId>
72
72
  proof:<proofId>
73
73
  facet:<facetKey>:<valueKey>
74
- default
74
+ '' (universal / collection-wide fallback)
75
75
  ```
76
76
 
77
77
  Notes:
78
78
 
79
79
  - Variants and batches are **always nested under a product** in the SDK, so their refs include `productId`.
80
80
  - `facet:` refs are **collection-wide** — facets cross products by design.
81
- - `default` is a single record per (app, recordType) used as the global fallback.
81
+ - `''` (empty ref) is the **universal** record — one per `(app, recordType)` with no scope restrictions; the collection-wide fallback.
82
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
83
 
84
84
  ### Adding scopes later
85
85
 
86
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
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
+
88
103
  ---
89
104
 
90
105
  ## 4. Resolution order (REQUIRED)
@@ -92,7 +107,7 @@ If a new axis appears (e.g. `region:eu`), pick a new prefix and document it. Nev
92
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:
93
108
 
94
109
  ```
95
- proof → batch → variant → product → facet(*) → default
110
+ proof → batch → variant → product → facet(*) → universal
96
111
  ```
97
112
 
98
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.
@@ -208,7 +223,7 @@ If you are using `<RecordsAdminShell>`, the bulk actions menu is included and wi
208
223
 
209
224
  ## 9. CSV import / export
210
225
 
211
- Adopt this column shape across all records-based apps:
226
+ If your app exposes CSV import/export, use this column shape so files round-trip across apps:
212
227
 
213
228
  ```
214
229
  scope,scopeRef,<field1>,<field2>,...
@@ -220,18 +235,22 @@ facet,bread_type/sourdough,240,11.0,...
220
235
 
221
236
  - `scope` is the kind; `scopeRef` is the human-readable reference (the shell maps it to the canonical `ref`).
222
237
  - Validation errors return a downloadable annotated CSV with an `error` column appended.
223
- - Round-tripping (export → reimport unchanged) MUST be a no-op.
238
+
239
+ #### If you ship CSV
240
+
241
+ - Round-tripping (export → reimport unchanged) must be a no-op.
224
242
 
225
243
  ---
226
244
 
227
245
  ## 10. Public-side hook contract
228
246
 
229
- To keep widgets consistent across apps, expose one hook per record type (implemented in `@proveanything/ui-utils`):
247
+ To keep widgets consistent across apps, expose one hook per record type (from `@proveanything/smartlinks-utils-ui`):
230
248
 
231
249
  ```ts
232
250
  import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
233
251
 
234
252
  const { data, source, isLoading } = useResolvedRecord({
253
+ SL,
235
254
  appId,
236
255
  recordType: 'nutrition',
237
256
  // any combination of these — the hook walks the chain:
@@ -239,13 +258,13 @@ const { data, source, isLoading } = useResolvedRecord({
239
258
  });
240
259
  ```
241
260
 
242
- `source` is one of `'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'default' | null`. UI can show a badge ("Showing batch-specific values") when useful.
261
+ `source` is one of `'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'universal' | null`. UI can show a badge ("Showing batch-specific values") when useful.
243
262
 
244
263
  ---
245
264
 
246
265
  ## 11. Telemetry
247
266
 
248
- All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanything/ui-utils` wires them automatically using `interactions.appendEvent` from the SDK — apps don't call this themselves.
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.
249
268
 
250
269
  | Event | Props |
251
270
  |------------------------|------------------------------------------------------|
@@ -278,11 +297,11 @@ All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanythi
278
297
 
279
298
  ## Appendix A — `ref` parser reference
280
299
 
281
- This is the canonical implementation. Copy it into your app or import from `@proveanything/ui-utils/records`.
300
+ This is the canonical implementation. Copy it into your app or import from `@proveanything/smartlinks-utils-ui/records-admin`.
282
301
 
283
302
  ```ts
284
303
  type ParsedRef =
285
- | { kind: 'default' }
304
+ | { kind: 'universal' }
286
305
  | { kind: 'product'; productId: string }
287
306
  | { kind: 'variant'; productId: string; variantId: string }
288
307
  | { kind: 'batch'; productId: string; batchId: string }
@@ -291,7 +310,7 @@ type ParsedRef =
291
310
 
292
311
  export const buildRef = (p: ParsedRef): string => {
293
312
  switch (p.kind) {
294
- case 'default': return 'default';
313
+ case 'universal': return '';
295
314
  case 'product': return `product:${p.productId}`;
296
315
  case 'variant': return `variant:${p.productId}:${p.variantId}`;
297
316
  case 'batch': return `batch:${p.productId}:${p.batchId}`;
@@ -301,7 +320,7 @@ export const buildRef = (p: ParsedRef): string => {
301
320
  };
302
321
 
303
322
  export const parseRef = (ref: string): ParsedRef | null => {
304
- if (ref === 'default') return { kind: 'default' };
323
+ if (!ref) return { kind: 'universal' };
305
324
  const [head, ...rest] = ref.split(':');
306
325
  switch (head) {
307
326
  case 'product': return rest.length === 1 ? { kind: 'product', productId: rest[0] } : null;
@@ -82,7 +82,7 @@ import * as SL from '@proveanything/smartlinks';
82
82
  scopes={['facet', 'product', 'variant', 'batch']}
83
83
  contextScope={{ productId, variantId, batchId }} // from iframe URL — optional
84
84
  defaultData={() => ({})}
85
- csvSchema={{ columns: [/* ... */] }}
85
+ csvSchema={{ columns: [/* ... */] }} // optional — omit to disable CSV import/export
86
86
  renderEditor={(ctx) => <NutritionForm ctx={ctx} />}
87
87
  renderPreview={({ resolved }) => <pre>{JSON.stringify(resolved, null, 2)}</pre>}
88
88
  />
@@ -97,7 +97,7 @@ import * as SL from '@proveanything/smartlinks';
97
97
  - **Collection-aware tabs**: calls `collection.get` and hides Variants / Batches tabs unless `collection.variants` / `collection.batches` are true — no flicker
98
98
  - **Server-side pagination** via `useInfiniteQuery` — handles thousands of products with a "Load more" button
99
99
  - **Context-aware**: pass `contextScope` from your iframe URL (`productId` / `variantId` / `batchId`) and the browser is constrained to that subtree with the right tab auto-selected
100
- - **CSV import / export** with a schema you define; failed rows come back as an annotated CSV
100
+ - **CSV import / export** (optional provide `csvSchema` to enable); failed rows come back as an annotated CSV
101
101
  - **Bulk actions menu** (apply-to-many, copy-from, clear) via `bulkUpsert` / `bulkDelete`
102
102
  - **Telemetry hook** (`onTelemetry`) emits typed events for save, delete, scope change, CSV import/export, bulk apply
103
103
  - **i18n strings** fully overridable
@@ -136,7 +136,7 @@ const { data, source, isLoading } = useResolvedRecord({
136
136
  batchId, // optional
137
137
  proofId, // optional
138
138
  });
139
- // source: 'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'default' | null
139
+ // source: 'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'universal' | null
140
140
  ```
141
141
 
142
142
  Walks the resolution chain defined in [records-admin-pattern.md §4](records-admin-pattern.md#4-resolution-order-required). Use this on the **public widget side** to read the correct value for a given context.