@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.
@@ -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,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 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);
547
-
548
- for (const record of records) {
549
- switch (record.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 'facet': /* "Tier-specific" */ break;
555
- case 'universal': /* "Default" */ break;
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 specificity order: `proof > batch > variant > product > facet > universal`.
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
- scope: { productId: 'prod_abc' },
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', scope: { productId: 'prod_abc' }, data: { calories: 250 } },
588
- { 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 } },
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 scope anchor (all records under a product)
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
- scope: { productId },
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 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:
688
796
 
689
797
  ```
690
- [facet:{key}={val1}+{val2}/][product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
798
+ [product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
691
799
  ```
692
800
 
693
801
  Examples:
694
802
 
695
- | Scope | Derived ref |
696
- |-------|-------------|
697
- | `{ productId: 'prod_abc' }` | `product:prod_abc` |
698
- | `{ productId: 'prod_abc', variantId: 'var_500ml' }` | `product:prod_abc/variant:var_500ml` |
699
- | `{ batchId: 'batch_q1' }` | `batch:batch_q1` |
700
- | `{ facets: [{ key: 'tier', valueKeys: ['gold'] }] }` | `facet:tier=gold` |
701
- | `{}` | `''` (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) |
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