@proveanything/smartlinks 1.10.1 → 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.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.10.1 | Generated: 2026-04-25T15:35:27.893Z
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
 
@@ -1901,27 +1901,6 @@ interface FacetRule {
1901
1901
  }
1902
1902
  ```
1903
1903
 
1904
- **ScopeFacetClause** (interface)
1905
- ```typescript
1906
- interface ScopeFacetClause {
1907
- key: string
1908
- valueKeys: string[]
1909
- }
1910
- ```
1911
-
1912
- **RecordScope** (interface)
1913
- ```typescript
1914
- interface RecordScope {
1915
- productId?: string
1916
- variantId?: string
1917
- proofId?: string
1918
- batchId?: string
1919
- * Arbitrary facet clauses.
1920
- * Clauses are ANDed together; valueKeys within a clause are ORed.
1921
- facets?: ScopeFacetClause[]
1922
- }
1923
- ```
1924
-
1925
1904
  **RecordTarget** (interface)
1926
1905
  ```typescript
1927
1906
  interface RecordTarget {
@@ -1929,8 +1908,10 @@ interface RecordTarget {
1929
1908
  variantId?: string
1930
1909
  proofId?: string
1931
1910
  batchId?: string
1932
- * Facet values the caller possesses, keyed by facet key.
1933
- * A scope clause is satisfied if ANY of the clause's valueKeys appears here.
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.
1934
1915
  facets?: Record<string, string[]>
1935
1916
  }
1936
1917
  ```
@@ -1940,12 +1921,15 @@ interface RecordTarget {
1940
1921
  interface BulkUpsertItem {
1941
1922
  ref: string
1942
1923
  recordType?: string
1943
- customId?: string
1944
- sourceSystem?: string
1924
+ productId?: string | null
1925
+ variantId?: string | null
1926
+ batchId?: string | null
1927
+ proofId?: string | null
1928
+ customId?: string | null
1929
+ sourceSystem?: string | null
1945
1930
  startsAt?: string | null
1946
1931
  expiresAt?: string | null
1947
1932
  status?: string | null
1948
- scope?: RecordScope
1949
1933
  data?: Record<string, unknown> | null
1950
1934
  metadata?: Record<string, unknown> | null
1951
1935
  facetRule?: FacetRule | null
@@ -1972,26 +1956,12 @@ interface BulkDeleteResult {
1972
1956
  }
1973
1957
  ```
1974
1958
 
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
-
1988
1959
  **MatchResult** (interface)
1989
1960
  ```typescript
1990
1961
  interface MatchResult {
1991
- records: MatchEntry[]
1992
- * Only present when strategy is 'best'.
1993
- * The single highest-specificity record per recordType.
1994
- best?: Record<string, MatchEntry>
1962
+ data: MatchEntry[]
1963
+ total: number
1964
+ strategy: 'all' | 'best'
1995
1965
  }
1996
1966
  ```
1997
1967
 
@@ -2000,12 +1970,15 @@ interface MatchResult {
2000
1970
  interface UpsertRecordInput {
2001
1971
  ref: string
2002
1972
  recordType?: string
2003
- customId?: string
2004
- sourceSystem?: string
1973
+ productId?: string | null
1974
+ variantId?: string | null
1975
+ batchId?: string | null
1976
+ proofId?: string | null
1977
+ customId?: string | null
1978
+ sourceSystem?: string | null
2005
1979
  startsAt?: string | null
2006
1980
  expiresAt?: string | null
2007
1981
  status?: string | null
2008
- scope?: RecordScope
2009
1982
  data?: Record<string, unknown> | null
2010
1983
  metadata?: Record<string, unknown> | null
2011
1984
  facetRule?: FacetRule | null
@@ -2037,10 +2010,15 @@ interface AppRecord {
2037
2010
  visibility: Visibility
2038
2011
  recordType: string | null
2039
2012
  ref: string | null
2013
+ scopeType: string | null
2014
+ scopeId: string | null
2040
2015
  customId: string | null
2016
+ customIdNormalized: string | null
2041
2017
  sourceSystem: string | null
2042
2018
  status: string | null
2043
2019
  productId: string | null
2020
+ variantId: string | null
2021
+ batchId: string | null
2044
2022
  proofId: string | null
2045
2023
  contactId: string | null
2046
2024
  authorId: string | null
@@ -2052,16 +2030,13 @@ interface AppRecord {
2052
2030
  startsAt: string | null
2053
2031
  expiresAt: string | null
2054
2032
  deletedAt: string | null // admin only
2055
- * Structured scope definition. Empty object means universal.
2056
- * Platform-canonicalized on write (keys sorted, valueKeys deduplicated).
2057
- scope: RecordScope
2058
- * Numeric specificity score computed from scope.
2059
- * 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).
2060
2035
  specificity: number
2061
2036
  * Facet rule for rule records (ref starts with "rule:").
2062
- * null on all other record types. Mutually exclusive with scope.
2063
- * SDK 1.10.
2037
+ * null on all other record types. Mutually exclusive with anchor IDs.
2064
2038
  facetRule: FacetRule | null
2039
+ singletonKey: string | null
2065
2040
  data: Record<string, unknown>
2066
2041
  owner: Record<string, unknown>
2067
2042
  admin: Record<string, unknown> // admin only
@@ -2072,25 +2047,31 @@ interface AppRecord {
2072
2047
  **CreateRecordInput** (interface)
2073
2048
  ```typescript
2074
2049
  interface CreateRecordInput {
2075
- recordType: string
2076
- visibility?: Visibility // default 'owner'
2077
- ref?: string // derived from scope if omitted and scope provided
2078
- status?: string // default 'active'
2079
- productId?: string
2080
- proofId?: string
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
2081
2058
  contactId?: string
2082
2059
  authorId?: string
2083
2060
  authorType?: string
2084
2061
  parentType?: string
2085
2062
  parentId?: string
2086
- startsAt?: string // ISO 8601
2087
- expiresAt?: string
2088
- scope?: RecordScope
2089
- customId?: string
2090
- sourceSystem?: string
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
2091
2072
  data?: Record<string, unknown>
2092
2073
  owner?: Record<string, unknown>
2093
- admin?: Record<string, unknown> // admin only
2074
+ admin?: Record<string, unknown>
2094
2075
  metadata?: Record<string, unknown>
2095
2076
  facetRule?: FacetRule | null
2096
2077
  }
@@ -2106,11 +2087,16 @@ interface UpdateRecordInput {
2106
2087
  visibility?: Visibility
2107
2088
  ref?: string
2108
2089
  recordType?: string
2109
- startsAt?: string
2110
- expiresAt?: string
2111
- scope?: RecordScope
2112
- customId?: string
2113
- sourceSystem?: string
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
2114
2100
  metadata?: Record<string, unknown>
2115
2101
  facetRule?: FacetRule | null
2116
2102
  }
@@ -2141,12 +2127,32 @@ interface ResolveAllParams {
2141
2127
  **ResolveAllResult** (interface)
2142
2128
  ```typescript
2143
2129
  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
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[]>
2150
2156
  }
2151
2157
  ```
2152
2158
 
@@ -2154,6 +2160,7 @@ interface ResolveAllResult {
2154
2160
  ```typescript
2155
2161
  interface PreviewRuleParams {
2156
2162
  facetRule: FacetRule
2163
+ recordType?: string
2157
2164
  limit?: number
2158
2165
  }
2159
2166
  ```
@@ -2161,8 +2168,9 @@ interface PreviewRuleParams {
2161
2168
  **PreviewRuleResult** (interface)
2162
2169
  ```typescript
2163
2170
  interface PreviewRuleResult {
2164
- sampleProductIds: string[]
2165
- totalMatches: number
2171
+ matchingProducts: Array<{ productId: string; name?: string; facets: Record<string, string[]> }>
2172
+ total: number
2173
+ rule: FacetRule
2166
2174
  }
2167
2175
  ```
2168
2176
 
@@ -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 scoping** — each record can declare which products, variants, batches, proofs, or facet values it applies to, and the platform can match records against a runtime context.
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
- - **Scoped targeting** — `scope` object restricts which products/variants/facets the record applies to
454
- - **Specificity scoring** — `specificity` enables "best match" resolution across multiple scoped records
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 scope if omitted
459
+ - **References** — optional `ref` field for external IDs; auto-derived from anchor fields if omitted
459
460
 
460
- ### Scoped Records
461
+ ### Targeted Records
461
462
 
462
- Every record has a `scope` object. An empty scope (`{}`) means the record is universal — it applies everywhere. A populated scope restricts which context the record applies to.
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 scoped to a specific product
468
+ // A nutrition record anchored to a specific product
468
469
  await app.records.create(collectionId, appId, {
469
470
  recordType: 'nutrition',
470
- scope: { productId: 'prod_abc' },
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
- scope: { productId: 'prod_abc', variantId: 'var_500ml' },
478
+ productId: 'prod_abc',
479
+ variantId: 'var_500ml',
478
480
  data: { calories: 260, protein: 12.5 },
479
481
  }, true);
480
482
 
481
- // A record scoped to a facet value (applies to all products with tier=gold)
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
- scope: {
485
- facets: [{ key: 'tier', valueKeys: ['gold', 'platinum'] }]
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 when `scope` is provided and `ref` is omitted:
493
+ The `ref` field is derived automatically from anchor fields when omitted:
492
494
 
493
495
  ```
494
- scope: { productId: 'prod_abc' } → ref: 'product:prod_abc'
495
- scope: { productId: 'prod_abc', variantId: 'var_x' } → ref: 'product:prod_abc/variant:var_x'
496
- scope: {} → ref: '' (universal)
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
- | Scope element | Points |
504
- |---------------|--------|
506
+ | Field / element | Points |
507
+ |-----------------|--------|
505
508
  | `proofId` | +1000 |
506
509
  | `batchId` | +500 |
507
510
  | `variantId` | +250 |
508
511
  | `productId` | +100 |
509
- | Per facet clause | +10 |
510
- | Per facet value key | +1 |
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 { records } = await app.records.match(collectionId, appId, {
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
- // records is ordered by specificity descending — most specific first
526
- // each record carries a `matchedAt` field indicating which scope dimension matched
563
+ // data is ordered by specificity descending — most specific first
564
+ // each entry carries a `matchedAt` field indicating which dimension matched
527
565
 
528
- // Or use strategy: 'best' to get the single winner per recordType
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.nutrition → the highest-specificity nutrition record for this context
571
+ // best[0] → the highest-specificity record for this context
534
572
  ```
535
573
 
536
574
  Facet matching rules:
@@ -540,18 +578,18 @@ Facet matching rules:
540
578
 
541
579
  #### `matchedAt` — match attribution
542
580
 
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:
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 { records } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true);
584
+ const { data } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true);
547
585
 
548
- for (const entry of records) {
586
+ for (const entry of data) {
549
587
  switch (entry.matchedAt) {
588
+ case 'rule': /* "Matches rule" */ break;
550
589
  case 'proof': /* "Scan-specific" */ break;
551
590
  case 'batch': /* "Batch-specific" */ break;
552
591
  case 'variant': /* "Size-specific" */ break;
553
592
  case 'product': /* "Inherited from product" */ break;
554
- case 'rule': /* "Matches rule" */ break;
555
593
  case 'facet': /* "Tier-specific" */ break;
556
594
  case 'collection': /* "Collection default" */ break;
557
595
  case 'universal': /* "Default" */ break;
@@ -559,7 +597,7 @@ for (const entry of records) {
559
597
  }
560
598
  ```
561
599
 
562
- Precedence follows: `proof > batch > variant > product > rule > facet > collection > universal`.
600
+ Precedence follows: `rule > proof > batch > variant > product > facet > collection > universal`.
563
601
 
564
602
  #### React — `useResolvedRecord`
565
603
 
@@ -583,19 +621,19 @@ await app.records.create(collectionId, appId, {
583
621
  }, true);
584
622
  ```
585
623
 
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.
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.
587
625
 
588
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.
589
627
 
590
628
  Use `records.previewRule()` to see which products a rule would match before creating it:
591
629
 
592
630
  ```typescript
593
- const { sampleProductIds, totalMatches } = await app.records.previewRule(collectionId, appId, {
631
+ const { matchingProducts, total } = await app.records.previewRule(collectionId, appId, {
594
632
  facetRule: {
595
633
  all: [{ facetKey: 'brand', anyOf: ['samsung'] }],
596
634
  },
597
635
  });
598
- // totalMatches: 42, sampleProductIds: ['prod_001', 'prod_002', ...]
636
+ // total: 42, matchingProducts: [{ productId: 'prod_001', facets: {...} }, ...]
599
637
  ```
600
638
 
601
639
  ### Resolve All
@@ -604,7 +642,7 @@ Use `app.records.resolveAll()` to fetch **every applicable record for a product
604
642
 
605
643
  ```typescript
606
644
  // All records that apply to this product context (admin)
607
- const { records, truncated } = await app.records.resolveAll(collectionId, appId, {
645
+ const { records, total, truncated } = await app.records.resolveAll(collectionId, appId, {
608
646
  context: {
609
647
  productId: 'prod_001',
610
648
  facets: { brand: 'samsung', type: 'tv' },
@@ -641,7 +679,7 @@ Create-or-update a record by `ref` in a single call:
641
679
  const { created } = await app.records.upsert(collectionId, appId, {
642
680
  ref: 'product:prod_abc',
643
681
  recordType: 'nutrition',
644
- scope: { productId: 'prod_abc' },
682
+ productId: 'prod_abc',
645
683
  data: { calories: 250, protein: 12.5 },
646
684
  });
647
685
  // created: true if new, false if updated
@@ -654,8 +692,8 @@ Upsert or delete large sets of records efficiently:
654
692
  ```typescript
655
693
  // Bulk upsert up to 500 records in one transaction
656
694
  const result = await app.records.bulkUpsert(collectionId, appId, [
657
- { ref: 'product:prod_abc', recordType: 'nutrition', scope: { productId: 'prod_abc' }, data: { calories: 250 } },
658
- { ref: 'product:prod_xyz', recordType: 'nutrition', scope: { productId: 'prod_xyz' }, data: { calories: 180 } },
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 } },
659
697
  ]);
660
698
  // result: { saved: 2, failed: 0, results: [...] }
661
699
 
@@ -665,7 +703,7 @@ await app.records.bulkDelete(collectionId, appId, {
665
703
  recordType: 'nutrition',
666
704
  });
667
705
 
668
- // Bulk delete by scope anchor (all records under a product)
706
+ // Bulk delete by anchor (all records under a product)
669
707
  await app.records.bulkDelete(collectionId, appId, {
670
708
  scope: { productId: 'prod_abc' },
671
709
  });
@@ -719,7 +757,7 @@ await app.records.upsert(collectionId, appId, {
719
757
  customId: slug,
720
758
  sourceSystem: 'contentful',
721
759
  recordType: 'content_page',
722
- scope: { productId },
760
+ productId,
723
761
  data: { title, body },
724
762
  });
725
763
  // upsert finds-or-creates by ref deterministically,
@@ -754,21 +792,21 @@ await app.records.aggregate(collectionId, appId, {
754
792
 
755
793
  ### Canonical Ref Format
756
794
 
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:
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:
758
796
 
759
797
  ```
760
- [facet:{key}={val1}+{val2}/][product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
798
+ [product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
761
799
  ```
762
800
 
763
801
  Examples:
764
802
 
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) |
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) |
772
810
 
773
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)).
774
812