@proveanything/smartlinks 1.11.10 → 1.11.12

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.11.10 | Generated: 2026-05-03T11:56:35.287Z
3
+ Version: 1.11.12 | Generated: 2026-05-05T09:01:25.366Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -32,7 +32,7 @@ If you only remember one rule: **never write your own resolution loop**. The ser
32
32
 
33
33
  ## 1. The data model in one paragraph
34
34
 
35
- A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
35
+ A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). Records also carry a `status` (`'active'` | `'draft'` | `'archived'`) and optional `startsAt` / `expiresAt` timestamps. The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
36
36
 
37
37
  ```ts
38
38
  import * as SL from '@proveanything/smartlinks';
@@ -219,11 +219,12 @@ const result = await SL.app.records.match(collectionId, appId, {
219
219
  recordType: 'ingredients',
220
220
  });
221
221
 
222
- // result.best.ingredients → the single highest-specificity record
223
- // result.best.ingredients.matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
222
+ // result.data[0] → the single highest-specificity MatchEntry (when strategy: 'best')
223
+ // result.data[0].matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
224
+ // result.data[0].data → your record payload
224
225
  ```
225
226
 
226
- The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match.
227
+ The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match. `result.data` will have at most one entry when `strategy: 'best'`.
227
228
 
228
229
  ### 5b. Collection — `app.records.resolveAll()` (every match, ordered)
229
230
 
@@ -231,25 +232,69 @@ Use when the widget shows **many** answers across the chain (FAQs, recipes, care
231
232
 
232
233
  ```ts
233
234
  const result = await SL.app.records.resolveAll(collectionId, appId, {
234
- target: { productId },
235
- recordTypes: ['faq'],
235
+ context: { productId }, // note: resolveAll uses 'context', not 'target'
236
+ recordType: 'faq', // singular — omit to return all record types
236
237
  });
237
238
 
238
- // result.records → AppRecord[] sorted most-specific first
239
- // each record has .matchedAt, .data, .scope
239
+ // result.records → ResolveAllEntry[] sorted most-specific first
240
+ // each entry: { record: AppRecord, matchedAt, specificity, matchedRule? }
240
241
  ```
241
242
 
242
- ### 5c. Multi-type — `app.records.resolveAll()` with multiple record types
243
+ ### 5c. Multi-type — `app.records.resolveAll()` without a recordType filter
243
244
 
244
- When you need records of several types in one call (rare; executors, SEO surfaces):
245
+ When you need records of all types for a context in one call (rare; executors, SEO surfaces), omit `recordType`:
245
246
 
246
247
  ```ts
247
248
  const result = await SL.app.records.resolveAll(collectionId, appId, {
248
- target: { productId, facets: { brand: 'acme' } },
249
- recordTypes: ['ingredients', 'nutrition', 'allergens'],
249
+ context: {
250
+ productId,
251
+ facets: { brand: ['acme'] }, // include facets to match rule records
252
+ },
253
+ // no recordType → returns all declared types
250
254
  });
255
+
256
+ // result.records → one ResolveAllEntry per matched record, all types interleaved
257
+ // filter client-side by entry.record.recordType if you need to separate them
258
+ ```
259
+
260
+ ### 5d. Status filtering and the draft → active lifecycle
261
+
262
+ Every record has a `status` field with three canonical values:
263
+
264
+ | Value | Meaning | Returned to public/owner callers? |
265
+ |---|---|---|
266
+ | `active` | Live and current | ✅ Yes |
267
+ | `draft` | Being prepared, not yet published | ❌ No |
268
+ | `archived` | Previously live, retained for history | ❌ No |
269
+
270
+ **Enforcement:** `match()`, `resolveAll()`, and `GET /records` (query) now only return `status: "active"` records to public and owner callers. Admin callers receive all statuses as before; use explicit `status` filters (`status=draft`, `status=archived`) to narrow results.
271
+
272
+ **`active` is the default** when no `status` is supplied on creation, so existing records and simple creation flows are unaffected.
273
+
274
+ **Draft → publish workflow:** create the record with `status: 'draft'` so it is invisible to public widgets, then update it to `status: 'active'` when ready to publish.
275
+
276
+ ```ts
277
+ // Create a record that is not yet publicly visible
278
+ await SL.app.records.upsert(collectionId, appId, {
279
+ recordType: 'ingredients',
280
+ scope: { productId },
281
+ data: { /* draft payload */ },
282
+ status: 'draft',
283
+ }, /* admin */ true);
284
+
285
+ // Publish it
286
+ await SL.app.records.upsert(collectionId, appId, {
287
+ recordType: 'ingredients',
288
+ scope: { productId },
289
+ data: { /* final payload */ },
290
+ status: 'active',
291
+ }, true);
251
292
  ```
252
293
 
294
+ **Composes with `startsAt` / `expiresAt`:** a record must satisfy **both** the status check and the time window to be returned to public callers. A record that is `active` but whose `startsAt` is in the future, or whose `expiresAt` has passed, is excluded.
295
+
296
+ ---
297
+
253
298
  ### Common mistakes (do not do these)
254
299
 
255
300
  | ❌ Anti-pattern | ✅ Do this instead |
@@ -263,6 +308,8 @@ const result = await SL.app.records.resolveAll(collectionId, appId, {
263
308
  | Walking the chain by hand with multiple `get` / `list` calls | One `match()` or `resolveAll()` call. The server handles the resolution order including rules. |
264
309
  | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
265
310
  | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
311
+ | Expecting `draft` or `archived` records to appear in public widget results | Public/owner callers only receive `status: "active"` records from `match()`, `resolveAll()`, and `GET /records`. Use admin calls to query by other statuses. |
312
+ | Setting `status: 'active'` and wondering why a record is still hidden | Check `startsAt` / `expiresAt` — a record must satisfy both the status check and the time window. |
266
313
 
267
314
  ---
268
315
 
@@ -307,6 +354,7 @@ interface EditorContext<TData> {
307
354
  5. **Replace any handwritten chain walking** with `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). If you are using React, the `useResolvedRecord` / `useCollectedRecords` hooks from `@proveanything/smartlinks-utils-ui` wrap these calls — but they are **admin-side React helpers**, not for public widgets.
308
355
  6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>` (React admin) or pass `facetRule` directly in `upsert()` calls.
309
356
  7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
357
+ 8. **Audit records that should not be public yet:** any record that previously relied on obscurity (e.g. not linked in the widget, no active product) is now filtered by `status`. Set `status: 'draft'` on records that are not ready and `status: 'active'` when publishing. Records without an explicit status were created as `active` and are unaffected.
310
358
 
311
359
  ---
312
360
 
@@ -19,8 +19,11 @@ Complete guide to registering and discovering navigable states in SmartLinks app
19
19
  - [Portal Navigation Menu](#portal-navigation-menu)
20
20
  - [AI Orchestration](#ai-orchestration)
21
21
  - [Cross-App Navigation](#cross-app-navigation)
22
+ - [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
22
23
  - [TypeScript Types](#typescript-types)
23
- - [Rules & Best Practices](#rules--best-practices)
24
+ - [Supplier Responsibilities](#supplier-responsibilities)
25
+ - [Consumer Responsibilities](#consumer-responsibilities)
26
+ - [Anti-Patterns](#anti-patterns)
24
27
 
25
28
  ---
26
29
 
@@ -72,6 +75,33 @@ Consumers merge both sources to get the full set of navigable states for an app.
72
75
 
73
76
  ---
74
77
 
78
+ ## Two Roles: Suppliers and Consumers
79
+
80
+ Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
81
+
82
+ | Role | What you do | Primary surface |
83
+ |------|-------------|-----------------|
84
+ | **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
85
+ | **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
86
+
87
+ Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
88
+
89
+ ### Supplier checklist
90
+
91
+ - [ ] Static navigable views declared in `app.manifest.json#linkable`
92
+ - [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
93
+ - [ ] Every entry has a concise, human-readable `title`
94
+
95
+ ### Consumer checklist
96
+
97
+ - [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
98
+ - [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
99
+ - [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
100
+ - [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
101
+ - [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
102
+
103
+ ---
104
+
75
105
  ## Two Sources of Truth
76
106
 
77
107
  ### Static Links — App Manifest
@@ -462,6 +492,126 @@ if (entry) {
462
492
  }
463
493
  ```
464
494
 
495
+ `onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
496
+
497
+ ---
498
+
499
+ ### Building a Cross-App Deep-Link Picker
500
+
501
+ When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App → Page), and persist the result as `AppDeepLink`.
502
+
503
+ #### Why not a free-text URL?
504
+
505
+ Hard-coded URLs break when:
506
+ - The target app is re-deployed on a new domain.
507
+ - The user is inside a container and full-page navigation would destroy their session.
508
+ - Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
509
+
510
+ #### Step 1 — Discover installed apps
511
+
512
+ ```typescript
513
+ const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
514
+ // Returns all installed apps, each with its manifest and saved config
515
+ ```
516
+
517
+ #### Step 2 — Merge linkable entries for the chosen app
518
+
519
+ ```typescript
520
+ const entries: DeepLinkEntry[] = [
521
+ ...(app.manifest?.linkable ?? []),
522
+ ...(app.config?.linkable ?? []),
523
+ ];
524
+ ```
525
+
526
+ If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
527
+
528
+ #### Step 3 — Persist as `AppDeepLink`, never as a URL
529
+
530
+ ```typescript
531
+ // Correct — portable, context-preserving
532
+ const link: AppDeepLink = {
533
+ appId: app.appId,
534
+ path: entry.path ?? '/',
535
+ params: entry.params,
536
+ label: entry.title, // cache for display; re-resolve at render time
537
+ };
538
+ ```
539
+
540
+ The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
541
+
542
+ #### Step 4 — Navigate at runtime
543
+
544
+ ```typescript
545
+ const { onNavigate } = useAppContext();
546
+
547
+ onNavigate({
548
+ appId: link.appId,
549
+ deepLink: link.path,
550
+ params: link.params,
551
+ });
552
+ ```
553
+
554
+ #### Worked example — Raffle "See the prizes" button
555
+
556
+ ```tsx
557
+ // --- Admin side ---
558
+ function PrizesLinkField({ collectionId, value, onChange }) {
559
+ const [apps, setApps] = useState([]);
560
+
561
+ useEffect(() => {
562
+ SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
563
+ }, [collectionId]);
564
+
565
+ return (
566
+ <>
567
+ {/* App dropdown */}
568
+ <select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
569
+ <option value="">— Choose an app —</option>
570
+ {apps.map(a => (
571
+ <option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
572
+ ))}
573
+ </select>
574
+
575
+ {/* Page dropdown for chosen app */}
576
+ {value?.appId && (() => {
577
+ const app = apps.find(a => a.appId === value.appId);
578
+ const entries = [
579
+ ...(app?.manifest?.linkable ?? []),
580
+ ...(app?.config?.linkable ?? []),
581
+ ];
582
+ if (entries.length === 0) {
583
+ return <select disabled><option>Default route only</option></select>;
584
+ }
585
+ return (
586
+ <select onChange={e => {
587
+ const entry = entries[+e.target.value];
588
+ onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
589
+ }}>
590
+ {entries.map((entry, i) => (
591
+ <option key={i} value={i}>{entry.title}</option>
592
+ ))}
593
+ </select>
594
+ );
595
+ })()}
596
+ </>
597
+ );
598
+ }
599
+
600
+ // --- Public side ---
601
+ const { onNavigate } = useAppContext();
602
+ {prizesLink && (
603
+ <button onClick={() => onNavigate({
604
+ appId: prizesLink.appId,
605
+ deepLink: prizesLink.path,
606
+ params: prizesLink.params,
607
+ })}>
608
+ See {prizesLink.label ?? 'the prizes'}
609
+ </button>
610
+ )}
611
+ ```
612
+
613
+ > **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
614
+
465
615
  ---
466
616
 
467
617
  ## TypeScript Types
@@ -489,28 +639,72 @@ export interface DeepLinkEntry {
489
639
 
490
640
  /** Convenience alias for an array of DeepLinkEntry */
491
641
  export type DeepLinkRegistry = DeepLinkEntry[];
642
+
643
+ /**
644
+ * A cross-app deep link persisted by a consumer app's config.
645
+ * Never store a raw URL — store this instead.
646
+ *
647
+ * At render time, re-resolve via SL.collection.getAppsConfig to detect
648
+ * renamed or removed pages.
649
+ */
650
+ export interface AppDeepLink {
651
+ /** The target app's appId */
652
+ appId: string;
653
+ /** Hash route path within the target app (e.g. "/prizes/2026") */
654
+ path: string;
655
+ /** App-specific query params (platform context is injected automatically) */
656
+ params?: Record<string, string>;
657
+ /**
658
+ * Cached display label from the time of selection.
659
+ * For display only — do not use for navigation decisions.
660
+ */
661
+ label?: string;
662
+ }
492
663
  ```
493
664
 
494
665
  ---
495
666
 
496
- ## Rules & Best Practices
667
+ ## Supplier Responsibilities
497
668
 
498
- ### Rules
669
+ Rules for apps that **expose** navigable states to the platform:
499
670
 
500
671
  1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
501
672
  2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
502
673
  3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
503
674
  4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
504
675
  5. **`title` is required** — every entry must have a human-readable label.
505
- 6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
506
- 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
507
- 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
508
-
509
- ### Best Practices
510
-
511
- - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
512
- - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
513
- - **Sync dynamic links eagerly** trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
514
- - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
515
- - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
516
- - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
676
+ 6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
677
+ 7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
678
+ 8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
679
+
680
+ ---
681
+
682
+ ## Consumer Responsibilities
683
+
684
+ Rules for apps that **navigate to** states in other installed apps:
685
+
686
+ 1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
687
+ 2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
688
+ 3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
689
+ 4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
690
+ 5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
691
+ 6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
692
+ 7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
693
+ 8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
694
+
695
+ ---
696
+
697
+ ## Anti-Patterns
698
+
699
+ | ❌ Anti-pattern | ✅ Correct approach |
700
+ |----------------|---------------------|
701
+ | Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
702
+ | Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
703
+ | Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
704
+ | `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
705
+ | Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
706
+ | Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
707
+ | Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
708
+ | Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
709
+ | Re-implementing picker logic per app | Share a canonical picker component across your app suite |
710
+
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.10 | Generated: 2026-05-03T11:56:35.287Z
3
+ Version: 1.11.12 | Generated: 2026-05-05T09:01:25.366Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -32,7 +32,7 @@ If you only remember one rule: **never write your own resolution loop**. The ser
32
32
 
33
33
  ## 1. The data model in one paragraph
34
34
 
35
- A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
35
+ A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). Records also carry a `status` (`'active'` | `'draft'` | `'archived'`) and optional `startsAt` / `expiresAt` timestamps. The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
36
36
 
37
37
  ```ts
38
38
  import * as SL from '@proveanything/smartlinks';
@@ -219,11 +219,12 @@ const result = await SL.app.records.match(collectionId, appId, {
219
219
  recordType: 'ingredients',
220
220
  });
221
221
 
222
- // result.best.ingredients → the single highest-specificity record
223
- // result.best.ingredients.matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
222
+ // result.data[0] → the single highest-specificity MatchEntry (when strategy: 'best')
223
+ // result.data[0].matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
224
+ // result.data[0].data → your record payload
224
225
  ```
225
226
 
226
- The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match.
227
+ The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match. `result.data` will have at most one entry when `strategy: 'best'`.
227
228
 
228
229
  ### 5b. Collection — `app.records.resolveAll()` (every match, ordered)
229
230
 
@@ -231,25 +232,69 @@ Use when the widget shows **many** answers across the chain (FAQs, recipes, care
231
232
 
232
233
  ```ts
233
234
  const result = await SL.app.records.resolveAll(collectionId, appId, {
234
- target: { productId },
235
- recordTypes: ['faq'],
235
+ context: { productId }, // note: resolveAll uses 'context', not 'target'
236
+ recordType: 'faq', // singular — omit to return all record types
236
237
  });
237
238
 
238
- // result.records → AppRecord[] sorted most-specific first
239
- // each record has .matchedAt, .data, .scope
239
+ // result.records → ResolveAllEntry[] sorted most-specific first
240
+ // each entry: { record: AppRecord, matchedAt, specificity, matchedRule? }
240
241
  ```
241
242
 
242
- ### 5c. Multi-type — `app.records.resolveAll()` with multiple record types
243
+ ### 5c. Multi-type — `app.records.resolveAll()` without a recordType filter
243
244
 
244
- When you need records of several types in one call (rare; executors, SEO surfaces):
245
+ When you need records of all types for a context in one call (rare; executors, SEO surfaces), omit `recordType`:
245
246
 
246
247
  ```ts
247
248
  const result = await SL.app.records.resolveAll(collectionId, appId, {
248
- target: { productId, facets: { brand: 'acme' } },
249
- recordTypes: ['ingredients', 'nutrition', 'allergens'],
249
+ context: {
250
+ productId,
251
+ facets: { brand: ['acme'] }, // include facets to match rule records
252
+ },
253
+ // no recordType → returns all declared types
250
254
  });
255
+
256
+ // result.records → one ResolveAllEntry per matched record, all types interleaved
257
+ // filter client-side by entry.record.recordType if you need to separate them
258
+ ```
259
+
260
+ ### 5d. Status filtering and the draft → active lifecycle
261
+
262
+ Every record has a `status` field with three canonical values:
263
+
264
+ | Value | Meaning | Returned to public/owner callers? |
265
+ |---|---|---|
266
+ | `active` | Live and current | ✅ Yes |
267
+ | `draft` | Being prepared, not yet published | ❌ No |
268
+ | `archived` | Previously live, retained for history | ❌ No |
269
+
270
+ **Enforcement:** `match()`, `resolveAll()`, and `GET /records` (query) now only return `status: "active"` records to public and owner callers. Admin callers receive all statuses as before; use explicit `status` filters (`status=draft`, `status=archived`) to narrow results.
271
+
272
+ **`active` is the default** when no `status` is supplied on creation, so existing records and simple creation flows are unaffected.
273
+
274
+ **Draft → publish workflow:** create the record with `status: 'draft'` so it is invisible to public widgets, then update it to `status: 'active'` when ready to publish.
275
+
276
+ ```ts
277
+ // Create a record that is not yet publicly visible
278
+ await SL.app.records.upsert(collectionId, appId, {
279
+ recordType: 'ingredients',
280
+ scope: { productId },
281
+ data: { /* draft payload */ },
282
+ status: 'draft',
283
+ }, /* admin */ true);
284
+
285
+ // Publish it
286
+ await SL.app.records.upsert(collectionId, appId, {
287
+ recordType: 'ingredients',
288
+ scope: { productId },
289
+ data: { /* final payload */ },
290
+ status: 'active',
291
+ }, true);
251
292
  ```
252
293
 
294
+ **Composes with `startsAt` / `expiresAt`:** a record must satisfy **both** the status check and the time window to be returned to public callers. A record that is `active` but whose `startsAt` is in the future, or whose `expiresAt` has passed, is excluded.
295
+
296
+ ---
297
+
253
298
  ### Common mistakes (do not do these)
254
299
 
255
300
  | ❌ Anti-pattern | ✅ Do this instead |
@@ -263,6 +308,8 @@ const result = await SL.app.records.resolveAll(collectionId, appId, {
263
308
  | Walking the chain by hand with multiple `get` / `list` calls | One `match()` or `resolveAll()` call. The server handles the resolution order including rules. |
264
309
  | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
265
310
  | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
311
+ | Expecting `draft` or `archived` records to appear in public widget results | Public/owner callers only receive `status: "active"` records from `match()`, `resolveAll()`, and `GET /records`. Use admin calls to query by other statuses. |
312
+ | Setting `status: 'active'` and wondering why a record is still hidden | Check `startsAt` / `expiresAt` — a record must satisfy both the status check and the time window. |
266
313
 
267
314
  ---
268
315
 
@@ -307,6 +354,7 @@ interface EditorContext<TData> {
307
354
  5. **Replace any handwritten chain walking** with `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). If you are using React, the `useResolvedRecord` / `useCollectedRecords` hooks from `@proveanything/smartlinks-utils-ui` wrap these calls — but they are **admin-side React helpers**, not for public widgets.
308
355
  6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>` (React admin) or pass `facetRule` directly in `upsert()` calls.
309
356
  7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
357
+ 8. **Audit records that should not be public yet:** any record that previously relied on obscurity (e.g. not linked in the widget, no active product) is now filtered by `status`. Set `status: 'draft'` on records that are not ready and `status: 'active'` when publishing. Records without an explicit status were created as `active` and are unaffected.
310
358
 
311
359
  ---
312
360
 
@@ -19,8 +19,11 @@ Complete guide to registering and discovering navigable states in SmartLinks app
19
19
  - [Portal Navigation Menu](#portal-navigation-menu)
20
20
  - [AI Orchestration](#ai-orchestration)
21
21
  - [Cross-App Navigation](#cross-app-navigation)
22
+ - [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
22
23
  - [TypeScript Types](#typescript-types)
23
- - [Rules & Best Practices](#rules--best-practices)
24
+ - [Supplier Responsibilities](#supplier-responsibilities)
25
+ - [Consumer Responsibilities](#consumer-responsibilities)
26
+ - [Anti-Patterns](#anti-patterns)
24
27
 
25
28
  ---
26
29
 
@@ -72,6 +75,33 @@ Consumers merge both sources to get the full set of navigable states for an app.
72
75
 
73
76
  ---
74
77
 
78
+ ## Two Roles: Suppliers and Consumers
79
+
80
+ Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
81
+
82
+ | Role | What you do | Primary surface |
83
+ |------|-------------|-----------------|
84
+ | **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
85
+ | **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
86
+
87
+ Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
88
+
89
+ ### Supplier checklist
90
+
91
+ - [ ] Static navigable views declared in `app.manifest.json#linkable`
92
+ - [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
93
+ - [ ] Every entry has a concise, human-readable `title`
94
+
95
+ ### Consumer checklist
96
+
97
+ - [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
98
+ - [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
99
+ - [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
100
+ - [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
101
+ - [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
102
+
103
+ ---
104
+
75
105
  ## Two Sources of Truth
76
106
 
77
107
  ### Static Links — App Manifest
@@ -462,6 +492,126 @@ if (entry) {
462
492
  }
463
493
  ```
464
494
 
495
+ `onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
496
+
497
+ ---
498
+
499
+ ### Building a Cross-App Deep-Link Picker
500
+
501
+ When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App → Page), and persist the result as `AppDeepLink`.
502
+
503
+ #### Why not a free-text URL?
504
+
505
+ Hard-coded URLs break when:
506
+ - The target app is re-deployed on a new domain.
507
+ - The user is inside a container and full-page navigation would destroy their session.
508
+ - Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
509
+
510
+ #### Step 1 — Discover installed apps
511
+
512
+ ```typescript
513
+ const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
514
+ // Returns all installed apps, each with its manifest and saved config
515
+ ```
516
+
517
+ #### Step 2 — Merge linkable entries for the chosen app
518
+
519
+ ```typescript
520
+ const entries: DeepLinkEntry[] = [
521
+ ...(app.manifest?.linkable ?? []),
522
+ ...(app.config?.linkable ?? []),
523
+ ];
524
+ ```
525
+
526
+ If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
527
+
528
+ #### Step 3 — Persist as `AppDeepLink`, never as a URL
529
+
530
+ ```typescript
531
+ // Correct — portable, context-preserving
532
+ const link: AppDeepLink = {
533
+ appId: app.appId,
534
+ path: entry.path ?? '/',
535
+ params: entry.params,
536
+ label: entry.title, // cache for display; re-resolve at render time
537
+ };
538
+ ```
539
+
540
+ The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
541
+
542
+ #### Step 4 — Navigate at runtime
543
+
544
+ ```typescript
545
+ const { onNavigate } = useAppContext();
546
+
547
+ onNavigate({
548
+ appId: link.appId,
549
+ deepLink: link.path,
550
+ params: link.params,
551
+ });
552
+ ```
553
+
554
+ #### Worked example — Raffle "See the prizes" button
555
+
556
+ ```tsx
557
+ // --- Admin side ---
558
+ function PrizesLinkField({ collectionId, value, onChange }) {
559
+ const [apps, setApps] = useState([]);
560
+
561
+ useEffect(() => {
562
+ SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
563
+ }, [collectionId]);
564
+
565
+ return (
566
+ <>
567
+ {/* App dropdown */}
568
+ <select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
569
+ <option value="">— Choose an app —</option>
570
+ {apps.map(a => (
571
+ <option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
572
+ ))}
573
+ </select>
574
+
575
+ {/* Page dropdown for chosen app */}
576
+ {value?.appId && (() => {
577
+ const app = apps.find(a => a.appId === value.appId);
578
+ const entries = [
579
+ ...(app?.manifest?.linkable ?? []),
580
+ ...(app?.config?.linkable ?? []),
581
+ ];
582
+ if (entries.length === 0) {
583
+ return <select disabled><option>Default route only</option></select>;
584
+ }
585
+ return (
586
+ <select onChange={e => {
587
+ const entry = entries[+e.target.value];
588
+ onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
589
+ }}>
590
+ {entries.map((entry, i) => (
591
+ <option key={i} value={i}>{entry.title}</option>
592
+ ))}
593
+ </select>
594
+ );
595
+ })()}
596
+ </>
597
+ );
598
+ }
599
+
600
+ // --- Public side ---
601
+ const { onNavigate } = useAppContext();
602
+ {prizesLink && (
603
+ <button onClick={() => onNavigate({
604
+ appId: prizesLink.appId,
605
+ deepLink: prizesLink.path,
606
+ params: prizesLink.params,
607
+ })}>
608
+ See {prizesLink.label ?? 'the prizes'}
609
+ </button>
610
+ )}
611
+ ```
612
+
613
+ > **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
614
+
465
615
  ---
466
616
 
467
617
  ## TypeScript Types
@@ -489,28 +639,72 @@ export interface DeepLinkEntry {
489
639
 
490
640
  /** Convenience alias for an array of DeepLinkEntry */
491
641
  export type DeepLinkRegistry = DeepLinkEntry[];
642
+
643
+ /**
644
+ * A cross-app deep link persisted by a consumer app's config.
645
+ * Never store a raw URL — store this instead.
646
+ *
647
+ * At render time, re-resolve via SL.collection.getAppsConfig to detect
648
+ * renamed or removed pages.
649
+ */
650
+ export interface AppDeepLink {
651
+ /** The target app's appId */
652
+ appId: string;
653
+ /** Hash route path within the target app (e.g. "/prizes/2026") */
654
+ path: string;
655
+ /** App-specific query params (platform context is injected automatically) */
656
+ params?: Record<string, string>;
657
+ /**
658
+ * Cached display label from the time of selection.
659
+ * For display only — do not use for navigation decisions.
660
+ */
661
+ label?: string;
662
+ }
492
663
  ```
493
664
 
494
665
  ---
495
666
 
496
- ## Rules & Best Practices
667
+ ## Supplier Responsibilities
497
668
 
498
- ### Rules
669
+ Rules for apps that **expose** navigable states to the platform:
499
670
 
500
671
  1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
501
672
  2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
502
673
  3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
503
674
  4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
504
675
  5. **`title` is required** — every entry must have a human-readable label.
505
- 6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
506
- 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
507
- 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
508
-
509
- ### Best Practices
510
-
511
- - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
512
- - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
513
- - **Sync dynamic links eagerly** trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
514
- - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
515
- - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
516
- - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
676
+ 6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
677
+ 7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
678
+ 8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
679
+
680
+ ---
681
+
682
+ ## Consumer Responsibilities
683
+
684
+ Rules for apps that **navigate to** states in other installed apps:
685
+
686
+ 1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
687
+ 2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
688
+ 3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
689
+ 4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
690
+ 5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
691
+ 6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
692
+ 7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
693
+ 8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
694
+
695
+ ---
696
+
697
+ ## Anti-Patterns
698
+
699
+ | ❌ Anti-pattern | ✅ Correct approach |
700
+ |----------------|---------------------|
701
+ | Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
702
+ | Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
703
+ | Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
704
+ | `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
705
+ | Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
706
+ | Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
707
+ | Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
708
+ | Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
709
+ | Re-implementing picker logic per app | Share a canonical picker component across your app suite |
710
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.11.10",
3
+ "version": "1.11.12",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",