@proveanything/smartlinks 2.0.50 → 2.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/sequence.d.ts +3 -0
- package/dist/api/sequence.js +3 -0
- package/dist/docs/API_SUMMARY.md +83 -3
- package/dist/docs/comms.md +17 -1
- package/dist/docs/overview.md +1 -1
- package/dist/docs/sequences.md +6 -0
- package/dist/docs/server-functions.md +86 -3
- package/dist/openapi.yaml +106 -1
- package/dist/testing/index.d.ts +38 -0
- package/dist/testing/index.js +140 -7
- package/dist/types/appManifest.d.ts +89 -2
- package/dist/types/comms.d.ts +22 -0
- package/docs/API_SUMMARY.md +83 -3
- package/docs/comms.md +17 -1
- package/docs/overview.md +1 -1
- package/docs/sequences.md +6 -0
- package/docs/server-functions.md +86 -3
- package/openapi.yaml +106 -1
- package/package.json +1 -1
package/dist/api/sequence.d.ts
CHANGED
|
@@ -30,6 +30,9 @@ export declare namespace sequence {
|
|
|
30
30
|
* const { number, isNew } = await sequence.allocate(collectionId, {
|
|
31
31
|
* appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId,
|
|
32
32
|
* })
|
|
33
|
+
*
|
|
34
|
+
* @deprecated Being removed. Number things in your app's own server function with
|
|
35
|
+
* `ctx.sl.counters.next()` + `ctx.sl.appRecords.claim()` (docs/server-functions.md).
|
|
33
36
|
*/
|
|
34
37
|
function allocate(collectionId: string, input: AllocateSequenceInput): Promise<AllocatedSequence>;
|
|
35
38
|
}
|
package/dist/api/sequence.js
CHANGED
|
@@ -18,6 +18,9 @@ export var sequence;
|
|
|
18
18
|
* const { number, isNew } = await sequence.allocate(collectionId, {
|
|
19
19
|
* appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId,
|
|
20
20
|
* })
|
|
21
|
+
*
|
|
22
|
+
* @deprecated Being removed. Number things in your app's own server function with
|
|
23
|
+
* `ctx.sl.counters.next()` + `ctx.sl.appRecords.claim()` (docs/server-functions.md).
|
|
21
24
|
*/
|
|
22
25
|
async function allocate(collectionId, input) {
|
|
23
26
|
return post(`${base(collectionId)}/allocate`, input);
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.51 | Generated: 2026-10-10T07:57:23.889Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -2600,10 +2600,73 @@ interface AppDataHandle {
|
|
|
2600
2600
|
}
|
|
2601
2601
|
```
|
|
2602
2602
|
|
|
2603
|
+
**AppRecordClaimResult<T = any>** (interface)
|
|
2604
|
+
```typescript
|
|
2605
|
+
interface AppRecordClaimResult<T = any> {
|
|
2606
|
+
record: T;
|
|
2607
|
+
created: boolean;
|
|
2608
|
+
conflict?: 'singleton' | 'customId';
|
|
2609
|
+
}
|
|
2610
|
+
```
|
|
2611
|
+
|
|
2612
|
+
**ServerFunctionCounters** (interface)
|
|
2613
|
+
```typescript
|
|
2614
|
+
interface ServerFunctionCounters {
|
|
2615
|
+
next(name: string, opts?: { start?: number; by?: number }): Promise<number>;
|
|
2616
|
+
get(name: string): Promise<number | null>;
|
|
2617
|
+
}
|
|
2618
|
+
```
|
|
2619
|
+
|
|
2620
|
+
**ResolvedTag** (interface)
|
|
2621
|
+
```typescript
|
|
2622
|
+
interface ResolvedTag {
|
|
2623
|
+
id: string;
|
|
2624
|
+
claimSetId: string;
|
|
2625
|
+
code: string;
|
|
2626
|
+
}
|
|
2627
|
+
```
|
|
2628
|
+
|
|
2629
|
+
**ContactUpsertDetails** (interface)
|
|
2630
|
+
```typescript
|
|
2631
|
+
interface ContactUpsertDetails {
|
|
2632
|
+
email?: string;
|
|
2633
|
+
phone?: string;
|
|
2634
|
+
firstName?: string;
|
|
2635
|
+
lastName?: string;
|
|
2636
|
+
displayName?: string;
|
|
2637
|
+
company?: string;
|
|
2638
|
+
locale?: string;
|
|
2639
|
+
timezone?: string;
|
|
2640
|
+
customFields?: Record<string, string | number | boolean>;
|
|
2641
|
+
}
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
**ServerFunctionInteractionEvent** (interface)
|
|
2645
|
+
```typescript
|
|
2646
|
+
interface ServerFunctionInteractionEvent {
|
|
2647
|
+
interactionId: string;
|
|
2648
|
+
contactId?: string | null;
|
|
2649
|
+
outcome?: string;
|
|
2650
|
+
metadata?: Record<string, any>;
|
|
2651
|
+
productId?: string;
|
|
2652
|
+
proofId?: string;
|
|
2653
|
+
variantId?: string;
|
|
2654
|
+
batchId?: string;
|
|
2655
|
+
eventType?: string;
|
|
2656
|
+
}
|
|
2657
|
+
```
|
|
2658
|
+
|
|
2603
2659
|
**ServerFunctionSl** (interface)
|
|
2604
2660
|
```typescript
|
|
2605
2661
|
interface ServerFunctionSl {
|
|
2606
|
-
appRecords: any;
|
|
2662
|
+
appRecords: { claim(fields: Record<string, any>): Promise<AppRecordClaimResult>; [method: string]: any };
|
|
2663
|
+
counters: ServerFunctionCounters;
|
|
2664
|
+
tags: { resolve(id: string): Promise<ResolvedTag | null> };
|
|
2665
|
+
contacts: {
|
|
2666
|
+
upsert(details: ContactUpsertDetails): Promise<{ contactId: string; created: boolean }>;
|
|
2667
|
+
forCaller(): Promise<{ contactId: string } | null>;
|
|
2668
|
+
};
|
|
2669
|
+
interactions: { record(event: ServerFunctionInteractionEvent): Promise<{ eventId: string }> };
|
|
2607
2670
|
products: any;
|
|
2608
2671
|
attestations: any;
|
|
2609
2672
|
appData: AppDataHandle & { global: AppDataHandle; collection: AppDataHandle };
|
|
@@ -5229,8 +5292,11 @@ interface UnsubscribeResponse {
|
|
|
5229
5292
|
```typescript
|
|
5230
5293
|
interface CommsSignupRequest {
|
|
5231
5294
|
email: string
|
|
5295
|
+
templateId: string
|
|
5232
5296
|
topics?: string[]
|
|
5233
5297
|
firstName?: string
|
|
5298
|
+
fields?: { firstName?: string; lastName?: string; displayName?: string; company?: string; locale?: string; timezone?: string; customFields?: Record<string, string | number | boolean> }
|
|
5299
|
+
appId?: string
|
|
5234
5300
|
}
|
|
5235
5301
|
```
|
|
5236
5302
|
|
|
@@ -5597,9 +5663,23 @@ export interface UnsubscribeResponse { ok: true; applied?: { channels?: Record<s
|
|
|
5597
5663
|
// Public sign-up (double opt-in)
|
|
5598
5664
|
export interface CommsSignupRequest {
|
|
5599
5665
|
email: string
|
|
5666
|
+
/**
|
|
5667
|
+
* Required: YOUR app's confirmation email — a template in this collection (SL.template) whose body
|
|
5668
|
+
* uses `{{ action_url }}` for the confirm link (`{{ topics }}` lists what they signed up for). The
|
|
5669
|
+
* platform sends no default sign-up email.
|
|
5670
|
+
*/
|
|
5671
|
+
templateId: string
|
|
5600
5672
|
/** Topic ids from the collection's comm settings (e.g. ["newsletter"]). */
|
|
5601
5673
|
topics?: string[]
|
|
5602
5674
|
firstName?: string
|
|
5675
|
+
/**
|
|
5676
|
+
* The form's other answers — firstName/lastName/displayName/company/locale/timezone and
|
|
5677
|
+
* `customFields`. Applied to the contact when the address is confirmed, and only where the contact
|
|
5678
|
+
* has no value yet (never overwritten). Small: ~2 KB.
|
|
5679
|
+
*/
|
|
5680
|
+
fields?: { firstName?: string; lastName?: string; displayName?: string; company?: string; locale?: string; timezone?: string; customFields?: Record<string, string | number | boolean> }
|
|
5681
|
+
/** Your app id, recorded with the sent email. */
|
|
5682
|
+
appId?: string
|
|
5603
5683
|
}
|
|
5604
5684
|
export interface CommsSignupResponse { ok: true; pending: true }
|
|
5605
5685
|
|
|
@@ -12157,7 +12237,7 @@ Soft-delete a secret. DELETE /secrets/:ref
|
|
|
12157
12237
|
### sequence
|
|
12158
12238
|
|
|
12159
12239
|
**allocate**(collectionId: string, input: AllocateSequenceInput) → `Promise<AllocatedSequence>`
|
|
12160
|
-
Allocate (or return the existing) sequence number for a subject. Idempotent — safe to call on load (auto-enter) and on a button tap; a subject that already has a number gets it back with `isNew: false`. const { number, isNew } = await sequence.allocate(collectionId, { appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId, })
|
|
12240
|
+
Allocate (or return the existing) sequence number for a subject. Idempotent — safe to call on load (auto-enter) and on a button tap; a subject that already has a number gets it back with `isNew: false`. const { number, isNew } = await sequence.allocate(collectionId, { appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId, }) `ctx.sl.counters.next()` + `ctx.sl.appRecords.claim()` (docs/server-functions.md).
|
|
12161
12241
|
|
|
12162
12242
|
### sessions
|
|
12163
12243
|
|
package/dist/docs/comms.md
CHANGED
|
@@ -320,14 +320,30 @@ link; nothing is consented until it's clicked. The answer is always the same, so
|
|
|
320
320
|
used to find out who's registered.
|
|
321
321
|
|
|
322
322
|
```typescript
|
|
323
|
-
await comms.signup('collectionId', {
|
|
323
|
+
await comms.signup('collectionId', {
|
|
324
|
+
email: 'sam@example.com',
|
|
325
|
+
topics: ['newsletter'],
|
|
326
|
+
templateId: config.confirmationTemplateId, // YOUR confirmation email (required)
|
|
327
|
+
fields: { firstName: 'Sam', customFields: { favouriteStore: 'Leeds' } }, // optional
|
|
328
|
+
appId: 'my-newsletter',
|
|
329
|
+
})
|
|
324
330
|
// → { ok: true, pending: true } — tell the visitor to check their inbox
|
|
325
331
|
```
|
|
326
332
|
|
|
333
|
+
- **The confirmation email is the app's own** (emails belong to the app that runs the flow — there is
|
|
334
|
+
no platform default): a template in the collection, created and edited in the app's admin, e.g.
|
|
335
|
+
`SL.template.create(collectionId, { name: 'Confirm your subscription', type: 'email', engine: 'liquid',
|
|
336
|
+
subject: 'Please confirm your subscription', body: '…<a href="{{ action_url }}">Yes, subscribe me</a>…' })`.
|
|
337
|
+
It gets `{{ action_url }}` (the confirm link), `{{ topics }}` (labels) and `{{ contact.firstName }}`.
|
|
338
|
+
- **Form answers** (`fields`) are applied only once the address is confirmed, and only to the
|
|
339
|
+
contact's empty fields — a sign-up can never overwrite someone else's details.
|
|
340
|
+
|
|
327
341
|
- Topics must exist in the collection's comm settings. Give a newsletter topic
|
|
328
342
|
`defaults.policy: 'opt-in'`: broadcasts on an opt-in topic only reach people who said yes
|
|
329
343
|
(confirmed sign-ups), never the whole contact list.
|
|
330
344
|
- Confirming turns on the email channel and the topics, and marks the address verified.
|
|
345
|
+
- Don't use `contact.publicUpsert` + `comms.upsertConsent` for a signed-out form: consent calls act only
|
|
346
|
+
for the signed-in contact. `comms.signup` is the signed-out path.
|
|
331
347
|
- Set comm settings `signup.confirmedUrl` (https) to send people to your own "thanks" page after
|
|
332
348
|
they confirm (`?confirmed=1`, or `0` for an expired link); otherwise SmartLinks shows a short page.
|
|
333
349
|
|
package/dist/docs/overview.md
CHANGED
|
@@ -93,7 +93,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
93
93
|
| **Container Tracking** | `docs/container-tracking.md` | Physical/logical **item** container groupings — *not* app containers (see containers.md) |
|
|
94
94
|
| **Lots** | `docs/lots.md` | Cross-SKU production "Lot" entity |
|
|
95
95
|
| **Loyalty** | `docs/loyalty.md` | Points, members, earning rules |
|
|
96
|
-
| **Sequences** | `docs/sequences.md` |
|
|
96
|
+
| **Sequences** | `docs/sequences.md` | RETIRING — number things in your own server function: `ctx.sl.counters` + `appRecords.claim` (server-functions.md) |
|
|
97
97
|
| **Integrations** | `docs/integrations.md` | Inbound/outbound external-system integration flows |
|
|
98
98
|
| **Item Context** | `docs/item-context.md` | The `itemContext` container prop (serial / NFC authenticity context) |
|
|
99
99
|
| **Native Facade** | `docs/native-facade.md` | `host.native` / `SL.native` device-capability facade |
|
package/dist/docs/sequences.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Sequences & claim-order allocation
|
|
2
2
|
|
|
3
|
+
> **Retiring — don't build on this.** `SL.sequence.allocate` is being removed. Number things in your
|
|
4
|
+
> app's own server function instead: `ctx.sl.counters.next()` for the next number and
|
|
5
|
+
> `ctx.sl.appRecords.claim()` to key it to a tag/person and make sure no number is ever held twice —
|
|
6
|
+
> see server-functions.md → "Uniqueness, numbering and people". (The counter here lived inside the
|
|
7
|
+
> app config doc, where an admin save could rewind it.)
|
|
8
|
+
|
|
3
9
|
> **SmartLinks SDK 2.x** (current `latest`). Install `@proveanything/smartlinks@^2`.
|
|
4
10
|
|
|
5
11
|
A **sequence** hands out a guaranteed-unique, monotonic number — `1, 2, 3, …` — and stamps it
|
|
@@ -182,8 +182,9 @@ interface ServerFunctionContext {
|
|
|
182
182
|
appId: string
|
|
183
183
|
|
|
184
184
|
/** SmartLinks SDK, pre-scoped to your declared `authority`. Capabilities cap what it may do.
|
|
185
|
-
* Key resources: `sl.appRecords` (per-app records), `sl.appData`
|
|
186
|
-
* data — URLs, flags,
|
|
185
|
+
* Key resources: `sl.appRecords` (per-app records, incl. race-safe `claim`), `sl.appData`
|
|
186
|
+
* (general/app-wide config + data — URLs, flags, arrays), `sl.counters` (atomic numbering),
|
|
187
|
+
* `sl.tags`, `sl.contacts`, `sl.interactions`, `sl.products`, `sl.attestations`. */
|
|
187
188
|
sl: SmartLinks
|
|
188
189
|
|
|
189
190
|
/** Capability-gated secrets (need `secrets:<name>`). `get(name)` reads this app's own secret on the
|
|
@@ -506,7 +507,11 @@ Prefer the platform primitives above over a dependency; when you do need one, pi
|
|
|
506
507
|
| Read or write SmartLinks data | `ctx.sl.*` (records, products, attestations, …) | the matching `sl:<res>:<read\|write>` |
|
|
507
508
|
| Read/write your app's own data (config, URLs, counters, arrays) | `ctx.sl.appData.get()` / `ctx.sl.appData.set({…})` — collection-scoped by default; `ctx.sl.appData.global.*` (or `{ scope:'global' }`, or declare `dataScope:'global'`) for the app-wide bucket. It's your OWN namespace, so **any** authority may read/write it | `sl:data:read` / `sl:data:write` |
|
|
508
509
|
| Read ANOTHER app's data (as the caller may see it) | `ctx.sl.app('other-app').data.get()` — caller-authority, this collection, public-filtered, read-only | `sl:data:read` |
|
|
509
|
-
| Guarantee a UNIQUE claim (
|
|
510
|
+
| Guarantee a UNIQUE claim (one entry per tag, one-per-user, idempotency key, a number nobody else holds) | `ctx.sl.appRecords.claim({ …, singletonPer, customId })` → `{ record, created, conflict? }` — see below | `sl:records:write` |
|
|
511
|
+
| Hand out the next number (tickets, order numbers) | `ctx.sl.counters.next('tickets')` — atomic, distinct per caller | `sl:data:write` |
|
|
512
|
+
| Check a tapped tag is real | `ctx.sl.tags.resolve(proofId)` → `{ id, claimSetId, code }` or `null` | `sl:tags:read` |
|
|
513
|
+
| Put a visitor's details on a contact | `ctx.sl.contacts.upsert({ email, firstName, … })` → `{ contactId, created }` (fills empty fields only) | `sl:contacts:write` |
|
|
514
|
+
| Record "they did X" | `ctx.sl.interactions.record({ interactionId, contactId, outcome, metadata })` | `sl:interactions:write` |
|
|
510
515
|
|
|
511
516
|
Notes:
|
|
512
517
|
- **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
|
|
@@ -525,6 +530,81 @@ Notes:
|
|
|
525
530
|
|
|
526
531
|
---
|
|
527
532
|
|
|
533
|
+
## Uniqueness, numbering and people — the race-safe primitives
|
|
534
|
+
|
|
535
|
+
A public function is called by many people at once. These primitives make the outcome right no matter
|
|
536
|
+
how the calls interleave — the **database** decides, never a read-then-write in your code.
|
|
537
|
+
|
|
538
|
+
### `appRecords.claim(fields)` — a unique create
|
|
539
|
+
|
|
540
|
+
Like `create`, but the record names a **unique key**, and it's inserted only if that key is free.
|
|
541
|
+
Otherwise you get the record already holding it back, **unchanged** (never updated):
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
const { record, created, conflict } = await ctx.sl.appRecords.claim({
|
|
545
|
+
recordType: 'entry',
|
|
546
|
+
singletonPer: 'proof', proofId: tag.id, // key 1: one live entry per tag
|
|
547
|
+
customId: String(number), // key 2: one record per number, ever
|
|
548
|
+
data: { number },
|
|
549
|
+
})
|
|
550
|
+
// created: true → you got it
|
|
551
|
+
// created: false → conflict 'singleton' (this tag already has an entry: `record` is it)
|
|
552
|
+
// or 'customId' (that number is held by someone else: take another)
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
- **`singletonPer`** — `'collection' | 'product' | 'variant' | 'batch' | 'proof'`, with its anchor id
|
|
556
|
+
(`productId`, `proofId`, …). One **live** record per key; deleting it frees the key.
|
|
557
|
+
- **`customId`** — unique per `(recordType, scopeType, scopeId, sourceSystem)`, case-insensitive, and
|
|
558
|
+
**never reused** (deleted records keep theirs). Right for numbers that must never be handed out twice.
|
|
559
|
+
- Give at least one; with both, the record is created only if both are free.
|
|
560
|
+
- Of any number of concurrent claims on one key, **exactly one** gets `created: true`.
|
|
561
|
+
|
|
562
|
+
### `counters.next(name, { start?, by? })` — the next number
|
|
563
|
+
|
|
564
|
+
An atomic counter for your app in this collection: every caller gets a distinct value, even under a
|
|
565
|
+
burst (one row, incremented in one statement). The first call returns `start` (default 1). Counters only
|
|
566
|
+
move forward and live apart from your config, so no config save can rewind one. `counters.get(name)`
|
|
567
|
+
reads the last value handed out (`null` before the first). Name a new counter to start again from `start`.
|
|
568
|
+
|
|
569
|
+
**Counter + claim = a number nobody else can ever hold.** Take `next()`, then `claim` with
|
|
570
|
+
`customId: String(n)`. If the claim says `conflict: 'customId'`, take `next()` again. A failed or raced
|
|
571
|
+
attempt only leaves a gap — never a duplicate.
|
|
572
|
+
|
|
573
|
+
### `tags.resolve(id)` — is this tap real?
|
|
574
|
+
|
|
575
|
+
A tap hands your app the virtual-proof id `<claimSetId>-<code>`. `resolve` returns
|
|
576
|
+
`{ id, claimSetId, code }` when that code exists in a claim set of **this** collection, else `null`.
|
|
577
|
+
Key records to the returned canonical `id`, and refuse `null` — otherwise anyone can invent ids.
|
|
578
|
+
|
|
579
|
+
### `contacts.upsert(details)` / `contacts.forCaller()`
|
|
580
|
+
|
|
581
|
+
`upsert` finds the contact by `email` or `phone` (one is required) or creates it, and only **fills
|
|
582
|
+
empty fields** — it never overwrites a name or field the contact already has, because a visitor's typed
|
|
583
|
+
details are unverified. It's race-safe: two simultaneous entries with one email end on one contact.
|
|
584
|
+
Returns `{ contactId, created }`. `forCaller()` is the signed-in caller's own contact (`null` when
|
|
585
|
+
anonymous). **Never return contact ids to public callers** — keep them on your records.
|
|
586
|
+
|
|
587
|
+
This is not consent. A ticked "keep me updated" box is evidence you can record (below); subscribing
|
|
588
|
+
someone to marketing is the double opt-in flow (`comms.signup`, see comms.md).
|
|
589
|
+
|
|
590
|
+
### `interactions.record(event)` — "they did X"
|
|
591
|
+
|
|
592
|
+
Records an event of one of **your app's interaction types**: create the type once in admin with
|
|
593
|
+
`SL.interactions.ensureType(collectionId, { appId, key: 'raffle-entry' })`, keep its id in your app
|
|
594
|
+
config, and pass that id. Unknown ids are refused (`INTERACTION_TYPE_NOT_FOUND`). The event runs the
|
|
595
|
+
type's effects and emits `interaction.submitted`, like any submission.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
await ctx.sl.interactions.record({
|
|
599
|
+
interactionId: config.interactionIds.entered,
|
|
600
|
+
contactId, // optional
|
|
601
|
+
outcome: 'entered',
|
|
602
|
+
metadata: { number, consent: true },
|
|
603
|
+
})
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
528
608
|
## Worked example — a public competition entry
|
|
529
609
|
|
|
530
610
|
```ts
|
|
@@ -756,6 +836,9 @@ const ctx = createFunctionTestContext({
|
|
|
756
836
|
|
|
757
837
|
const res = await submitCompetitionEntry(ctx, { method: 'POST', body: { email: 'a@b.com', answer: '42' } })
|
|
758
838
|
// ctx.sl.appRecords.create(...) throws CapabilityError unless `def` declares sl:records:write
|
|
839
|
+
// With no `sl` impl, records / counters / contacts / interactions run in memory with the platform's
|
|
840
|
+
// uniqueness rules — assert on ctx.memory. Share createFunctionTestMemory() between contexts to
|
|
841
|
+
// simulate concurrent callers; restrict ids with knownTags / knownInteractionTypes.
|
|
759
842
|
```
|
|
760
843
|
|
|
761
844
|
Pass the **`def`** (not a hand-typed capability list) so "tested" can't drift from
|
package/dist/openapi.yaml
CHANGED
|
@@ -19218,16 +19218,113 @@ components:
|
|
|
19218
19218
|
AppDataHandle:
|
|
19219
19219
|
type: object
|
|
19220
19220
|
properties: {}
|
|
19221
|
+
AppRecordClaimResult:
|
|
19222
|
+
type: object
|
|
19223
|
+
properties:
|
|
19224
|
+
record:
|
|
19225
|
+
type: object
|
|
19226
|
+
additionalProperties: true
|
|
19227
|
+
created:
|
|
19228
|
+
type: boolean
|
|
19229
|
+
conflict:
|
|
19230
|
+
type: string
|
|
19231
|
+
enum:
|
|
19232
|
+
- singleton
|
|
19233
|
+
- customId
|
|
19234
|
+
required:
|
|
19235
|
+
- record
|
|
19236
|
+
- created
|
|
19237
|
+
ServerFunctionCounters:
|
|
19238
|
+
type: object
|
|
19239
|
+
properties: {}
|
|
19240
|
+
ResolvedTag:
|
|
19241
|
+
type: object
|
|
19242
|
+
properties:
|
|
19243
|
+
id:
|
|
19244
|
+
type: string
|
|
19245
|
+
claimSetId:
|
|
19246
|
+
type: string
|
|
19247
|
+
code:
|
|
19248
|
+
type: string
|
|
19249
|
+
required:
|
|
19250
|
+
- id
|
|
19251
|
+
- claimSetId
|
|
19252
|
+
- code
|
|
19253
|
+
ContactUpsertDetails:
|
|
19254
|
+
type: object
|
|
19255
|
+
properties:
|
|
19256
|
+
email:
|
|
19257
|
+
type: string
|
|
19258
|
+
phone:
|
|
19259
|
+
type: string
|
|
19260
|
+
firstName:
|
|
19261
|
+
type: string
|
|
19262
|
+
lastName:
|
|
19263
|
+
type: string
|
|
19264
|
+
displayName:
|
|
19265
|
+
type: string
|
|
19266
|
+
company:
|
|
19267
|
+
type: string
|
|
19268
|
+
locale:
|
|
19269
|
+
type: string
|
|
19270
|
+
timezone:
|
|
19271
|
+
type: string
|
|
19272
|
+
customFields:
|
|
19273
|
+
type: object
|
|
19274
|
+
additionalProperties:
|
|
19275
|
+
type: object
|
|
19276
|
+
additionalProperties: true
|
|
19277
|
+
ServerFunctionInteractionEvent:
|
|
19278
|
+
type: object
|
|
19279
|
+
properties:
|
|
19280
|
+
interactionId:
|
|
19281
|
+
type: string
|
|
19282
|
+
contactId:
|
|
19283
|
+
type: string
|
|
19284
|
+
outcome:
|
|
19285
|
+
type: string
|
|
19286
|
+
metadata:
|
|
19287
|
+
type: object
|
|
19288
|
+
additionalProperties: true
|
|
19289
|
+
productId:
|
|
19290
|
+
type: string
|
|
19291
|
+
proofId:
|
|
19292
|
+
type: string
|
|
19293
|
+
variantId:
|
|
19294
|
+
type: string
|
|
19295
|
+
batchId:
|
|
19296
|
+
type: string
|
|
19297
|
+
eventType:
|
|
19298
|
+
type: string
|
|
19299
|
+
required:
|
|
19300
|
+
- interactionId
|
|
19221
19301
|
ServerFunctionSl:
|
|
19222
19302
|
type: object
|
|
19223
19303
|
properties:
|
|
19224
|
-
appRecords:
|
|
19304
|
+
appRecords:
|
|
19305
|
+
type: object
|
|
19306
|
+
additionalProperties: true
|
|
19307
|
+
counters:
|
|
19308
|
+
$ref: "#/components/schemas/ServerFunctionCounters"
|
|
19309
|
+
tags:
|
|
19310
|
+
type: object
|
|
19311
|
+
additionalProperties: true
|
|
19312
|
+
contacts:
|
|
19313
|
+
type: object
|
|
19314
|
+
additionalProperties: true
|
|
19315
|
+
interactions:
|
|
19316
|
+
type: object
|
|
19317
|
+
additionalProperties: true
|
|
19225
19318
|
products: {}
|
|
19226
19319
|
attestations: {}
|
|
19227
19320
|
appData:
|
|
19228
19321
|
$ref: "#/components/schemas/AppDataHandle"
|
|
19229
19322
|
required:
|
|
19230
19323
|
- appRecords
|
|
19324
|
+
- counters
|
|
19325
|
+
- tags
|
|
19326
|
+
- contacts
|
|
19327
|
+
- interactions
|
|
19231
19328
|
- products
|
|
19232
19329
|
- attestations
|
|
19233
19330
|
- appData
|
|
@@ -23658,14 +23755,22 @@ components:
|
|
|
23658
23755
|
properties:
|
|
23659
23756
|
email:
|
|
23660
23757
|
type: string
|
|
23758
|
+
templateId:
|
|
23759
|
+
type: string
|
|
23661
23760
|
topics:
|
|
23662
23761
|
type: array
|
|
23663
23762
|
items:
|
|
23664
23763
|
type: string
|
|
23665
23764
|
firstName:
|
|
23666
23765
|
type: string
|
|
23766
|
+
fields:
|
|
23767
|
+
type: object
|
|
23768
|
+
additionalProperties: true
|
|
23769
|
+
appId:
|
|
23770
|
+
type: string
|
|
23667
23771
|
required:
|
|
23668
23772
|
- email
|
|
23773
|
+
- templateId
|
|
23669
23774
|
CommsSignupResponse:
|
|
23670
23775
|
type: object
|
|
23671
23776
|
properties:
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare class CapabilityError extends Error {
|
|
|
8
8
|
export interface TestSlImpl {
|
|
9
9
|
appRecords?: {
|
|
10
10
|
create?(fields: any): any;
|
|
11
|
+
claim?(fields: any): any;
|
|
11
12
|
update?(id: string, fields: any): any;
|
|
12
13
|
upsert?(fields: any): any;
|
|
13
14
|
delete?(id: string): any;
|
|
@@ -15,6 +16,21 @@ export interface TestSlImpl {
|
|
|
15
16
|
query?(params: any): any;
|
|
16
17
|
listTypes?(): any;
|
|
17
18
|
};
|
|
19
|
+
counters?: {
|
|
20
|
+
next?(name: string, opts?: any): any;
|
|
21
|
+
get?(name: string): any;
|
|
22
|
+
};
|
|
23
|
+
/** Tag lookup. Default: any `<claimSetId>-<code>` resolves (pass `knownTags` to restrict). */
|
|
24
|
+
tags?: {
|
|
25
|
+
resolve?(id: string): any;
|
|
26
|
+
};
|
|
27
|
+
contacts?: {
|
|
28
|
+
upsert?(details: any): any;
|
|
29
|
+
forCaller?(): any;
|
|
30
|
+
};
|
|
31
|
+
interactions?: {
|
|
32
|
+
record?(event: any): any;
|
|
33
|
+
};
|
|
18
34
|
products?: {
|
|
19
35
|
get?(id: string, opts?: any): any;
|
|
20
36
|
query?(body?: any, opts?: any): any;
|
|
@@ -64,7 +80,27 @@ export interface CreateFunctionTestContextOptions {
|
|
|
64
80
|
fetch?: typeof fetch;
|
|
65
81
|
/** What the platform would have prefetched as `ctx.inputs` (e.g. `{ entity: product }`). Default `{}`. */
|
|
66
82
|
inputs?: ServerFunctionInputs;
|
|
83
|
+
/** Tags that exist, as `<claimSetId>-<code>` ids — `ctx.sl.tags.resolve` returns null for any other. */
|
|
84
|
+
knownTags?: string[];
|
|
85
|
+
/** Interaction type ids that exist for this app — `interactions.record` refuses others. Default: any. */
|
|
86
|
+
knownInteractionTypes?: string[];
|
|
87
|
+
/** Share one in-memory store between several contexts (e.g. concurrent callers in one test). */
|
|
88
|
+
memory?: FunctionTestMemory;
|
|
89
|
+
}
|
|
90
|
+
/** The harness's in-memory platform state (when no impl is injected). */
|
|
91
|
+
export interface FunctionTestMemory {
|
|
92
|
+
records: any[];
|
|
93
|
+
counters: Record<string, number>;
|
|
94
|
+
contacts: Array<{
|
|
95
|
+
contactId: string;
|
|
96
|
+
email?: string;
|
|
97
|
+
phone?: string;
|
|
98
|
+
[k: string]: any;
|
|
99
|
+
}>;
|
|
100
|
+
interactions: any[];
|
|
67
101
|
}
|
|
102
|
+
/** A fresh in-memory store — pass the same one to several test contexts to simulate concurrent callers. */
|
|
103
|
+
export declare function createFunctionTestMemory(): FunctionTestMemory;
|
|
68
104
|
export interface FunctionTestContext extends ServerFunctionContext {
|
|
69
105
|
/** Captured log lines (also written via ctx.log). */
|
|
70
106
|
logs: Array<{
|
|
@@ -72,6 +108,8 @@ export interface FunctionTestContext extends ServerFunctionContext {
|
|
|
72
108
|
message: string;
|
|
73
109
|
data?: Record<string, any>;
|
|
74
110
|
}>;
|
|
111
|
+
/** The in-memory platform state this context reads and writes. */
|
|
112
|
+
memory: FunctionTestMemory;
|
|
75
113
|
}
|
|
76
114
|
/**
|
|
77
115
|
* Build a capability-enforcing test ctx for a server function. Run your handler with it:
|
package/dist/testing/index.js
CHANGED
|
@@ -14,6 +14,21 @@
|
|
|
14
14
|
// - `ctx.secrets` are FIXTURES you provide; real sealed secrets are server-only and
|
|
15
15
|
// never resolvable locally.
|
|
16
16
|
// - `ctx.fetch` is gated by the `network` capability just like production.
|
|
17
|
+
// - Without an injected impl, appRecords / counters / contacts / interactions run against an
|
|
18
|
+
// in-memory store with the platform's uniqueness rules (claim: singletonPer + customId; contacts by
|
|
19
|
+
// email/phone, fill-empty only), so idempotency and "never two of the same number" are testable.
|
|
20
|
+
// Inspect it via `ctx.memory`.
|
|
21
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
22
|
+
var t = {};
|
|
23
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
24
|
+
t[p] = s[p];
|
|
25
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
26
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
27
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
28
|
+
t[p[i]] = s[p[i]];
|
|
29
|
+
}
|
|
30
|
+
return t;
|
|
31
|
+
};
|
|
17
32
|
export class CapabilityError extends Error {
|
|
18
33
|
constructor(capability, message) {
|
|
19
34
|
super(message || `capability not granted: ${capability}`);
|
|
@@ -58,6 +73,10 @@ function allowsSl(p, resource, op) {
|
|
|
58
73
|
return true; // write implies read
|
|
59
74
|
return false;
|
|
60
75
|
}
|
|
76
|
+
/** A fresh in-memory store — pass the same one to several test contexts to simulate concurrent callers. */
|
|
77
|
+
export function createFunctionTestMemory() {
|
|
78
|
+
return { records: [], counters: {}, contacts: [], interactions: [] };
|
|
79
|
+
}
|
|
61
80
|
const stub = (method, args) => ({ __stub: true, method, args });
|
|
62
81
|
/**
|
|
63
82
|
* Build a capability-enforcing test ctx for a server function. Run your handler with it:
|
|
@@ -79,18 +98,75 @@ export function createFunctionTestContext(opts) {
|
|
|
79
98
|
throw new CapabilityError(`sl:${resource}:${op}`);
|
|
80
99
|
return fn ? fn(...args) : stub(`${resource}.${name}`, args);
|
|
81
100
|
};
|
|
101
|
+
const mem = opts.memory || createFunctionTestMemory();
|
|
82
102
|
const ar = impl.appRecords || {};
|
|
83
103
|
const pr = impl.products || {};
|
|
84
104
|
const at = impl.attestations || {};
|
|
105
|
+
// In-memory records with the platform's unique keys (used when no appRecords impl is injected).
|
|
106
|
+
const singletonKey = (f) => {
|
|
107
|
+
const t = f.recordType || 'default';
|
|
108
|
+
switch (f.singletonPer) {
|
|
109
|
+
case 'collection': return `${t}:collection`;
|
|
110
|
+
case 'product': return f.productId ? `${t}:product:${f.productId}` : null;
|
|
111
|
+
case 'variant': return f.productId && f.variantId ? `${t}:variant:${f.productId}:${f.variantId}` : null;
|
|
112
|
+
case 'batch': return f.productId && f.batchId ? `${t}:batch:${f.productId}:${f.batchId}` : null;
|
|
113
|
+
case 'proof': return f.proofId ? `${t}:proof:${f.proofId}` : null;
|
|
114
|
+
default: return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const customKey = (f) => {
|
|
118
|
+
const c = f.customId == null ? '' : String(f.customId).trim().toLowerCase();
|
|
119
|
+
return c ? [f.recordType || 'default', f.scopeType || '', f.scopeId || '', f.sourceSystem || '', c].join('|') : null;
|
|
120
|
+
};
|
|
121
|
+
let nextRecordId = 1;
|
|
122
|
+
const memRecords = {
|
|
123
|
+
create: async (fields) => {
|
|
124
|
+
const rec = Object.assign({ id: `rec-${nextRecordId++}`, recordType: 'default', data: {}, admin: {}, status: 'active', createdAt: new Date().toISOString() }, fields);
|
|
125
|
+
mem.records.push(rec);
|
|
126
|
+
return Object.assign({}, rec);
|
|
127
|
+
},
|
|
128
|
+
claim: async (fields) => {
|
|
129
|
+
const sk = singletonKey(fields);
|
|
130
|
+
const ck = customKey(fields);
|
|
131
|
+
if (fields.singletonPer && !sk)
|
|
132
|
+
throw Object.assign(new Error(`claim: singletonPer "${fields.singletonPer}" needs its anchor id`), { code: 'INVALID_CLAIM' });
|
|
133
|
+
if (!sk && !ck)
|
|
134
|
+
throw Object.assign(new Error('claim: give the record a unique key — singletonPer (+ anchor) or customId'), { code: 'INVALID_CLAIM' });
|
|
135
|
+
const bySingleton = sk ? mem.records.find((r) => !r.deletedAt && singletonKey(r) === sk) : null;
|
|
136
|
+
if (bySingleton)
|
|
137
|
+
return { record: Object.assign({}, bySingleton), created: false, conflict: 'singleton' };
|
|
138
|
+
const byCustom = ck ? mem.records.find((r) => customKey(r) === ck) : null; // deleted records keep their customId
|
|
139
|
+
if (byCustom)
|
|
140
|
+
return { record: Object.assign({}, byCustom), created: false, conflict: 'customId' };
|
|
141
|
+
return { record: await memRecords.create(fields), created: true };
|
|
142
|
+
},
|
|
143
|
+
get: async (id) => { const r = mem.records.find((x) => x.id === id); return r ? Object.assign({}, r) : null; },
|
|
144
|
+
update: async (id, fields) => { const r = mem.records.find((x) => x.id === id); if (!r)
|
|
145
|
+
return null; Object.assign(r, fields); return Object.assign({}, r); },
|
|
146
|
+
delete: async (id) => { const r = mem.records.find((x) => x.id === id); if (r)
|
|
147
|
+
r.deletedAt = new Date().toISOString(); return {}; },
|
|
148
|
+
// Simple equality filter on top-level fields (recordType, proofId, customId, status, …).
|
|
149
|
+
query: async (params = {}) => {
|
|
150
|
+
const _a = params || {}, { limit, offset, sort } = _a, where = __rest(_a, ["limit", "offset", "sort"]);
|
|
151
|
+
const all = mem.records.filter((r) => !r.deletedAt && Object.entries(where).every(([k, v]) => r[k] === v));
|
|
152
|
+
const from = offset || 0;
|
|
153
|
+
const size = limit || 200;
|
|
154
|
+
const data = all.slice(from, from + size).map((r) => (Object.assign({}, r)));
|
|
155
|
+
// Same envelope as the platform: { data, pagination }.
|
|
156
|
+
return { data, pagination: { total: all.length, limit: size, offset: from, hasMore: from + data.length < all.length } };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const recordsFn = (name) => ar[name] ? ar[name].bind(ar) : (impl.appRecords ? undefined : memRecords[name]);
|
|
85
160
|
const sl = {
|
|
86
161
|
appRecords: {
|
|
87
|
-
create: gated('records', 'write',
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
162
|
+
create: gated('records', 'write', recordsFn('create'), 'create'),
|
|
163
|
+
claim: gated('records', 'write', recordsFn('claim'), 'claim'),
|
|
164
|
+
update: gated('records', 'write', recordsFn('update'), 'update'),
|
|
165
|
+
upsert: gated('records', 'write', recordsFn('upsert'), 'upsert'),
|
|
166
|
+
delete: gated('records', 'write', recordsFn('delete'), 'delete'),
|
|
167
|
+
get: gated('records', 'read', recordsFn('get'), 'get'),
|
|
168
|
+
query: gated('records', 'read', recordsFn('query'), 'query'),
|
|
169
|
+
listTypes: gated('records', 'read', recordsFn('listTypes'), 'listTypes'),
|
|
94
170
|
},
|
|
95
171
|
products: {
|
|
96
172
|
get: gated('products', 'read', pr.get && pr.get.bind(pr), 'get'),
|
|
@@ -122,6 +198,62 @@ export function createFunctionTestContext(opts) {
|
|
|
122
198
|
delete memConfig[k]; }), 'delete'),
|
|
123
199
|
});
|
|
124
200
|
sl.appData = Object.assign(makeAppData(), { global: makeAppData(), collection: makeAppData() });
|
|
201
|
+
const cn = impl.counters || {};
|
|
202
|
+
sl.counters = {
|
|
203
|
+
next: gated('data', 'write', cn.next ? cn.next.bind(cn) : (async (name, o = {}) => {
|
|
204
|
+
var _a, _b;
|
|
205
|
+
const start = (_a = o.start) !== null && _a !== void 0 ? _a : 1;
|
|
206
|
+
const by = (_b = o.by) !== null && _b !== void 0 ? _b : 1;
|
|
207
|
+
mem.counters[name] = mem.counters[name] == null ? start : mem.counters[name] + by;
|
|
208
|
+
return mem.counters[name];
|
|
209
|
+
}), 'counters.next'),
|
|
210
|
+
get: gated('data', 'read', cn.get ? cn.get.bind(cn) : (async (name) => { var _a; return (_a = mem.counters[name]) !== null && _a !== void 0 ? _a : null; }), 'counters.get'),
|
|
211
|
+
};
|
|
212
|
+
const tg = impl.tags || {};
|
|
213
|
+
sl.tags = {
|
|
214
|
+
resolve: gated('tags', 'read', tg.resolve ? tg.resolve.bind(tg) : (async (id) => {
|
|
215
|
+
const s = String(id || '');
|
|
216
|
+
const i = s.indexOf('-');
|
|
217
|
+
if (i <= 0 || i >= s.length - 1)
|
|
218
|
+
return null;
|
|
219
|
+
if (opts.knownTags && !opts.knownTags.includes(s))
|
|
220
|
+
return null;
|
|
221
|
+
return { id: s, claimSetId: s.slice(0, i), code: s.slice(i + 1) };
|
|
222
|
+
}), 'tags.resolve'),
|
|
223
|
+
};
|
|
224
|
+
const ct = impl.contacts || {};
|
|
225
|
+
const fillable = ['firstName', 'lastName', 'displayName', 'company', 'locale', 'timezone'];
|
|
226
|
+
sl.contacts = {
|
|
227
|
+
upsert: gated('contacts', 'write', ct.upsert ? ct.upsert.bind(ct) : (async (d = {}) => {
|
|
228
|
+
const email = d.email ? String(d.email).trim().toLowerCase() : undefined;
|
|
229
|
+
const phone = d.phone ? String(d.phone).replace(/[^\d+]/g, '') : undefined;
|
|
230
|
+
if (!email && !phone)
|
|
231
|
+
throw Object.assign(new Error('contacts.upsert: an email or phone is required'), { code: 'CONTACT_IDENTITY_REQUIRED' });
|
|
232
|
+
const found = mem.contacts.find((c) => (email && c.email === email) || (phone && c.phone === phone));
|
|
233
|
+
if (found) {
|
|
234
|
+
for (const k of fillable)
|
|
235
|
+
if (d[k] && !found[k])
|
|
236
|
+
found[k] = d[k];
|
|
237
|
+
found.customFields = Object.assign(Object.assign({}, (d.customFields || {})), (found.customFields || {}));
|
|
238
|
+
return { contactId: found.contactId, created: false };
|
|
239
|
+
}
|
|
240
|
+
const contactId = `contact-${mem.contacts.length + 1}`;
|
|
241
|
+
mem.contacts.push(Object.assign(Object.assign({}, d), { email, phone, contactId }));
|
|
242
|
+
return { contactId, created: true };
|
|
243
|
+
}), 'contacts.upsert'),
|
|
244
|
+
forCaller: gated('contacts', 'write', ct.forCaller ? ct.forCaller.bind(ct) : (async () => (opts.caller && opts.caller.userId ? { contactId: `contact-of-${opts.caller.userId}` } : null)), 'contacts.forCaller'),
|
|
245
|
+
};
|
|
246
|
+
const it = impl.interactions || {};
|
|
247
|
+
sl.interactions = {
|
|
248
|
+
record: gated('interactions', 'write', it.record ? it.record.bind(it) : (async (event = {}) => {
|
|
249
|
+
if (!event.interactionId || (opts.knownInteractionTypes && !opts.knownInteractionTypes.includes(event.interactionId))) {
|
|
250
|
+
throw Object.assign(new Error(`"${event.interactionId}" is not an interaction type of this app`), { code: 'INTERACTION_TYPE_NOT_FOUND' });
|
|
251
|
+
}
|
|
252
|
+
const eventId = `event-${mem.interactions.length + 1}`;
|
|
253
|
+
mem.interactions.push(Object.assign(Object.assign({}, event), { eventId, userId: (opts.caller && opts.caller.userId) || null }));
|
|
254
|
+
return { eventId };
|
|
255
|
+
}), 'interactions.record'),
|
|
256
|
+
};
|
|
125
257
|
sl.app = (appId) => {
|
|
126
258
|
const other = (impl.app && impl.app(appId)) || {};
|
|
127
259
|
const od = other.data || {};
|
|
@@ -178,5 +310,6 @@ export function createFunctionTestContext(opts) {
|
|
|
178
310
|
},
|
|
179
311
|
inputs: Object.freeze(Object.assign({}, (opts.inputs || {}))),
|
|
180
312
|
logs,
|
|
313
|
+
memory: mem,
|
|
181
314
|
};
|
|
182
315
|
}
|
|
@@ -365,9 +365,96 @@ export interface AppDataHandle {
|
|
|
365
365
|
* - by CALLER identity — everything else (products, attestations, and `app(id)` cross-app reads)
|
|
366
366
|
* is bounded by what the invoking user could do through the permissioned API.
|
|
367
367
|
*/
|
|
368
|
+
/** `ctx.sl.appRecords.claim` result. `conflict` says which unique key was already held. */
|
|
369
|
+
export interface AppRecordClaimResult<T = any> {
|
|
370
|
+
record: T;
|
|
371
|
+
created: boolean;
|
|
372
|
+
conflict?: 'singleton' | 'customId';
|
|
373
|
+
}
|
|
374
|
+
/** `ctx.sl.counters` — this app's atomic named counters in the collection. */
|
|
375
|
+
export interface ServerFunctionCounters {
|
|
376
|
+
/** The next value — distinct for every caller, even under a burst. First call returns `start` (default 1). */
|
|
377
|
+
next(name: string, opts?: {
|
|
378
|
+
start?: number;
|
|
379
|
+
by?: number;
|
|
380
|
+
}): Promise<number>;
|
|
381
|
+
/** The last value handed out, or `null` before the first `next`. */
|
|
382
|
+
get(name: string): Promise<number | null>;
|
|
383
|
+
}
|
|
384
|
+
/** A tap's tag, resolved and checked to belong to this collection. */
|
|
385
|
+
export interface ResolvedTag {
|
|
386
|
+
/** Canonical virtual-proof id `<claimSetId>-<code>` — key things to THIS, not the raw input. */
|
|
387
|
+
id: string;
|
|
388
|
+
claimSetId: string;
|
|
389
|
+
code: string;
|
|
390
|
+
}
|
|
391
|
+
/** Contact details a function may pass to `ctx.sl.contacts.upsert` (plain strings, capped). */
|
|
392
|
+
export interface ContactUpsertDetails {
|
|
393
|
+
email?: string;
|
|
394
|
+
phone?: string;
|
|
395
|
+
firstName?: string;
|
|
396
|
+
lastName?: string;
|
|
397
|
+
displayName?: string;
|
|
398
|
+
company?: string;
|
|
399
|
+
locale?: string;
|
|
400
|
+
timezone?: string;
|
|
401
|
+
customFields?: Record<string, string | number | boolean>;
|
|
402
|
+
}
|
|
403
|
+
/** An interaction event recorded from a server function. */
|
|
404
|
+
export interface ServerFunctionInteractionEvent {
|
|
405
|
+
/** The interaction TYPE id — created first in admin with `interactions.ensureType`, kept in config. */
|
|
406
|
+
interactionId: string;
|
|
407
|
+
contactId?: string | null;
|
|
408
|
+
/** e.g. 'entered'. Letters, digits, `. : - _`. Default 'submitted'. */
|
|
409
|
+
outcome?: string;
|
|
410
|
+
metadata?: Record<string, any>;
|
|
411
|
+
productId?: string;
|
|
412
|
+
proofId?: string;
|
|
413
|
+
variantId?: string;
|
|
414
|
+
batchId?: string;
|
|
415
|
+
eventType?: string;
|
|
416
|
+
}
|
|
368
417
|
export interface ServerFunctionSl {
|
|
369
|
-
/**
|
|
370
|
-
|
|
418
|
+
/**
|
|
419
|
+
* THIS app's records (own namespace): create / update / upsert / delete / get / query / listTypes,
|
|
420
|
+
* plus `claim(fields)` — a race-safe UNIQUE create. The record names a unique key — `singletonPer`
|
|
421
|
+
* (+ its anchor, e.g. `{ singletonPer: 'proof', proofId }`) and/or `customId` — and is inserted only
|
|
422
|
+
* if the key is free; otherwise the record holding it comes back UNCHANGED with `created: false`.
|
|
423
|
+
* Of any number of concurrent claims on one key, exactly one is created (the database decides).
|
|
424
|
+
*/
|
|
425
|
+
appRecords: {
|
|
426
|
+
claim(fields: Record<string, any>): Promise<AppRecordClaimResult>;
|
|
427
|
+
[method: string]: any;
|
|
428
|
+
};
|
|
429
|
+
/** Atomic named counters for this app in this collection (`sl:data:write` / `sl:data:read`). */
|
|
430
|
+
counters: ServerFunctionCounters;
|
|
431
|
+
/** Tags (`sl:tags:read`): `resolve(id)` → the real tag in THIS collection, or `null` for a made-up id. */
|
|
432
|
+
tags: {
|
|
433
|
+
resolve(id: string): Promise<ResolvedTag | null>;
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Contacts (`sl:contacts:write`). `upsert` finds by email/phone or creates, and only FILLS EMPTY
|
|
437
|
+
* fields — never overwrites (details typed by a visitor are unverified). `forCaller` is the signed-in
|
|
438
|
+
* caller's own contact (null when anonymous). Never return contact ids to public callers.
|
|
439
|
+
*/
|
|
440
|
+
contacts: {
|
|
441
|
+
upsert(details: ContactUpsertDetails): Promise<{
|
|
442
|
+
contactId: string;
|
|
443
|
+
created: boolean;
|
|
444
|
+
}>;
|
|
445
|
+
forCaller(): Promise<{
|
|
446
|
+
contactId: string;
|
|
447
|
+
} | null>;
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* Interactions (`sl:interactions:write`). `record` stores an event of one of THIS app's interaction
|
|
451
|
+
* types (refused if the type doesn't exist), runs the type's effects and emits `interaction.submitted`.
|
|
452
|
+
*/
|
|
453
|
+
interactions: {
|
|
454
|
+
record(event: ServerFunctionInteractionEvent): Promise<{
|
|
455
|
+
eventId: string;
|
|
456
|
+
}>;
|
|
457
|
+
};
|
|
371
458
|
/** Products, scoped by caller authority + declared capability. */
|
|
372
459
|
products: any;
|
|
373
460
|
/** Attestations, scoped by caller authority + declared capability. */
|
package/dist/types/comms.d.ts
CHANGED
|
@@ -184,9 +184,31 @@ export interface UnsubscribeResponse {
|
|
|
184
184
|
}
|
|
185
185
|
export interface CommsSignupRequest {
|
|
186
186
|
email: string;
|
|
187
|
+
/**
|
|
188
|
+
* Required: YOUR app's confirmation email — a template in this collection (SL.template) whose body
|
|
189
|
+
* uses `{{ action_url }}` for the confirm link (`{{ topics }}` lists what they signed up for). The
|
|
190
|
+
* platform sends no default sign-up email.
|
|
191
|
+
*/
|
|
192
|
+
templateId: string;
|
|
187
193
|
/** Topic ids from the collection's comm settings (e.g. ["newsletter"]). */
|
|
188
194
|
topics?: string[];
|
|
189
195
|
firstName?: string;
|
|
196
|
+
/**
|
|
197
|
+
* The form's other answers — firstName/lastName/displayName/company/locale/timezone and
|
|
198
|
+
* `customFields`. Applied to the contact when the address is confirmed, and only where the contact
|
|
199
|
+
* has no value yet (never overwritten). Small: ~2 KB.
|
|
200
|
+
*/
|
|
201
|
+
fields?: {
|
|
202
|
+
firstName?: string;
|
|
203
|
+
lastName?: string;
|
|
204
|
+
displayName?: string;
|
|
205
|
+
company?: string;
|
|
206
|
+
locale?: string;
|
|
207
|
+
timezone?: string;
|
|
208
|
+
customFields?: Record<string, string | number | boolean>;
|
|
209
|
+
};
|
|
210
|
+
/** Your app id, recorded with the sent email. */
|
|
211
|
+
appId?: string;
|
|
190
212
|
}
|
|
191
213
|
export interface CommsSignupResponse {
|
|
192
214
|
ok: true;
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.51 | Generated: 2026-10-10T07:57:23.889Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -2600,10 +2600,73 @@ interface AppDataHandle {
|
|
|
2600
2600
|
}
|
|
2601
2601
|
```
|
|
2602
2602
|
|
|
2603
|
+
**AppRecordClaimResult<T = any>** (interface)
|
|
2604
|
+
```typescript
|
|
2605
|
+
interface AppRecordClaimResult<T = any> {
|
|
2606
|
+
record: T;
|
|
2607
|
+
created: boolean;
|
|
2608
|
+
conflict?: 'singleton' | 'customId';
|
|
2609
|
+
}
|
|
2610
|
+
```
|
|
2611
|
+
|
|
2612
|
+
**ServerFunctionCounters** (interface)
|
|
2613
|
+
```typescript
|
|
2614
|
+
interface ServerFunctionCounters {
|
|
2615
|
+
next(name: string, opts?: { start?: number; by?: number }): Promise<number>;
|
|
2616
|
+
get(name: string): Promise<number | null>;
|
|
2617
|
+
}
|
|
2618
|
+
```
|
|
2619
|
+
|
|
2620
|
+
**ResolvedTag** (interface)
|
|
2621
|
+
```typescript
|
|
2622
|
+
interface ResolvedTag {
|
|
2623
|
+
id: string;
|
|
2624
|
+
claimSetId: string;
|
|
2625
|
+
code: string;
|
|
2626
|
+
}
|
|
2627
|
+
```
|
|
2628
|
+
|
|
2629
|
+
**ContactUpsertDetails** (interface)
|
|
2630
|
+
```typescript
|
|
2631
|
+
interface ContactUpsertDetails {
|
|
2632
|
+
email?: string;
|
|
2633
|
+
phone?: string;
|
|
2634
|
+
firstName?: string;
|
|
2635
|
+
lastName?: string;
|
|
2636
|
+
displayName?: string;
|
|
2637
|
+
company?: string;
|
|
2638
|
+
locale?: string;
|
|
2639
|
+
timezone?: string;
|
|
2640
|
+
customFields?: Record<string, string | number | boolean>;
|
|
2641
|
+
}
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
**ServerFunctionInteractionEvent** (interface)
|
|
2645
|
+
```typescript
|
|
2646
|
+
interface ServerFunctionInteractionEvent {
|
|
2647
|
+
interactionId: string;
|
|
2648
|
+
contactId?: string | null;
|
|
2649
|
+
outcome?: string;
|
|
2650
|
+
metadata?: Record<string, any>;
|
|
2651
|
+
productId?: string;
|
|
2652
|
+
proofId?: string;
|
|
2653
|
+
variantId?: string;
|
|
2654
|
+
batchId?: string;
|
|
2655
|
+
eventType?: string;
|
|
2656
|
+
}
|
|
2657
|
+
```
|
|
2658
|
+
|
|
2603
2659
|
**ServerFunctionSl** (interface)
|
|
2604
2660
|
```typescript
|
|
2605
2661
|
interface ServerFunctionSl {
|
|
2606
|
-
appRecords: any;
|
|
2662
|
+
appRecords: { claim(fields: Record<string, any>): Promise<AppRecordClaimResult>; [method: string]: any };
|
|
2663
|
+
counters: ServerFunctionCounters;
|
|
2664
|
+
tags: { resolve(id: string): Promise<ResolvedTag | null> };
|
|
2665
|
+
contacts: {
|
|
2666
|
+
upsert(details: ContactUpsertDetails): Promise<{ contactId: string; created: boolean }>;
|
|
2667
|
+
forCaller(): Promise<{ contactId: string } | null>;
|
|
2668
|
+
};
|
|
2669
|
+
interactions: { record(event: ServerFunctionInteractionEvent): Promise<{ eventId: string }> };
|
|
2607
2670
|
products: any;
|
|
2608
2671
|
attestations: any;
|
|
2609
2672
|
appData: AppDataHandle & { global: AppDataHandle; collection: AppDataHandle };
|
|
@@ -5229,8 +5292,11 @@ interface UnsubscribeResponse {
|
|
|
5229
5292
|
```typescript
|
|
5230
5293
|
interface CommsSignupRequest {
|
|
5231
5294
|
email: string
|
|
5295
|
+
templateId: string
|
|
5232
5296
|
topics?: string[]
|
|
5233
5297
|
firstName?: string
|
|
5298
|
+
fields?: { firstName?: string; lastName?: string; displayName?: string; company?: string; locale?: string; timezone?: string; customFields?: Record<string, string | number | boolean> }
|
|
5299
|
+
appId?: string
|
|
5234
5300
|
}
|
|
5235
5301
|
```
|
|
5236
5302
|
|
|
@@ -5597,9 +5663,23 @@ export interface UnsubscribeResponse { ok: true; applied?: { channels?: Record<s
|
|
|
5597
5663
|
// Public sign-up (double opt-in)
|
|
5598
5664
|
export interface CommsSignupRequest {
|
|
5599
5665
|
email: string
|
|
5666
|
+
/**
|
|
5667
|
+
* Required: YOUR app's confirmation email — a template in this collection (SL.template) whose body
|
|
5668
|
+
* uses `{{ action_url }}` for the confirm link (`{{ topics }}` lists what they signed up for). The
|
|
5669
|
+
* platform sends no default sign-up email.
|
|
5670
|
+
*/
|
|
5671
|
+
templateId: string
|
|
5600
5672
|
/** Topic ids from the collection's comm settings (e.g. ["newsletter"]). */
|
|
5601
5673
|
topics?: string[]
|
|
5602
5674
|
firstName?: string
|
|
5675
|
+
/**
|
|
5676
|
+
* The form's other answers — firstName/lastName/displayName/company/locale/timezone and
|
|
5677
|
+
* `customFields`. Applied to the contact when the address is confirmed, and only where the contact
|
|
5678
|
+
* has no value yet (never overwritten). Small: ~2 KB.
|
|
5679
|
+
*/
|
|
5680
|
+
fields?: { firstName?: string; lastName?: string; displayName?: string; company?: string; locale?: string; timezone?: string; customFields?: Record<string, string | number | boolean> }
|
|
5681
|
+
/** Your app id, recorded with the sent email. */
|
|
5682
|
+
appId?: string
|
|
5603
5683
|
}
|
|
5604
5684
|
export interface CommsSignupResponse { ok: true; pending: true }
|
|
5605
5685
|
|
|
@@ -12157,7 +12237,7 @@ Soft-delete a secret. DELETE /secrets/:ref
|
|
|
12157
12237
|
### sequence
|
|
12158
12238
|
|
|
12159
12239
|
**allocate**(collectionId: string, input: AllocateSequenceInput) → `Promise<AllocatedSequence>`
|
|
12160
|
-
Allocate (or return the existing) sequence number for a subject. Idempotent — safe to call on load (auto-enter) and on a button tap; a subject that already has a number gets it back with `isNew: false`. const { number, isNew } = await sequence.allocate(collectionId, { appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId, })
|
|
12240
|
+
Allocate (or return the existing) sequence number for a subject. Idempotent — safe to call on load (auto-enter) and on a button tap; a subject that already has a number gets it back with `isNew: false`. const { number, isNew } = await sequence.allocate(collectionId, { appId: 'raffle-app', sequenceId: 'raffle', subjectId: claimSetId, }) `ctx.sl.counters.next()` + `ctx.sl.appRecords.claim()` (docs/server-functions.md).
|
|
12161
12241
|
|
|
12162
12242
|
### sessions
|
|
12163
12243
|
|
package/docs/comms.md
CHANGED
|
@@ -320,14 +320,30 @@ link; nothing is consented until it's clicked. The answer is always the same, so
|
|
|
320
320
|
used to find out who's registered.
|
|
321
321
|
|
|
322
322
|
```typescript
|
|
323
|
-
await comms.signup('collectionId', {
|
|
323
|
+
await comms.signup('collectionId', {
|
|
324
|
+
email: 'sam@example.com',
|
|
325
|
+
topics: ['newsletter'],
|
|
326
|
+
templateId: config.confirmationTemplateId, // YOUR confirmation email (required)
|
|
327
|
+
fields: { firstName: 'Sam', customFields: { favouriteStore: 'Leeds' } }, // optional
|
|
328
|
+
appId: 'my-newsletter',
|
|
329
|
+
})
|
|
324
330
|
// → { ok: true, pending: true } — tell the visitor to check their inbox
|
|
325
331
|
```
|
|
326
332
|
|
|
333
|
+
- **The confirmation email is the app's own** (emails belong to the app that runs the flow — there is
|
|
334
|
+
no platform default): a template in the collection, created and edited in the app's admin, e.g.
|
|
335
|
+
`SL.template.create(collectionId, { name: 'Confirm your subscription', type: 'email', engine: 'liquid',
|
|
336
|
+
subject: 'Please confirm your subscription', body: '…<a href="{{ action_url }}">Yes, subscribe me</a>…' })`.
|
|
337
|
+
It gets `{{ action_url }}` (the confirm link), `{{ topics }}` (labels) and `{{ contact.firstName }}`.
|
|
338
|
+
- **Form answers** (`fields`) are applied only once the address is confirmed, and only to the
|
|
339
|
+
contact's empty fields — a sign-up can never overwrite someone else's details.
|
|
340
|
+
|
|
327
341
|
- Topics must exist in the collection's comm settings. Give a newsletter topic
|
|
328
342
|
`defaults.policy: 'opt-in'`: broadcasts on an opt-in topic only reach people who said yes
|
|
329
343
|
(confirmed sign-ups), never the whole contact list.
|
|
330
344
|
- Confirming turns on the email channel and the topics, and marks the address verified.
|
|
345
|
+
- Don't use `contact.publicUpsert` + `comms.upsertConsent` for a signed-out form: consent calls act only
|
|
346
|
+
for the signed-in contact. `comms.signup` is the signed-out path.
|
|
331
347
|
- Set comm settings `signup.confirmedUrl` (https) to send people to your own "thanks" page after
|
|
332
348
|
they confirm (`?confirmed=1`, or `0` for an expired link); otherwise SmartLinks shows a short page.
|
|
333
349
|
|
package/docs/overview.md
CHANGED
|
@@ -93,7 +93,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
93
93
|
| **Container Tracking** | `docs/container-tracking.md` | Physical/logical **item** container groupings — *not* app containers (see containers.md) |
|
|
94
94
|
| **Lots** | `docs/lots.md` | Cross-SKU production "Lot" entity |
|
|
95
95
|
| **Loyalty** | `docs/loyalty.md` | Points, members, earning rules |
|
|
96
|
-
| **Sequences** | `docs/sequences.md` |
|
|
96
|
+
| **Sequences** | `docs/sequences.md` | RETIRING — number things in your own server function: `ctx.sl.counters` + `appRecords.claim` (server-functions.md) |
|
|
97
97
|
| **Integrations** | `docs/integrations.md` | Inbound/outbound external-system integration flows |
|
|
98
98
|
| **Item Context** | `docs/item-context.md` | The `itemContext` container prop (serial / NFC authenticity context) |
|
|
99
99
|
| **Native Facade** | `docs/native-facade.md` | `host.native` / `SL.native` device-capability facade |
|
package/docs/sequences.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Sequences & claim-order allocation
|
|
2
2
|
|
|
3
|
+
> **Retiring — don't build on this.** `SL.sequence.allocate` is being removed. Number things in your
|
|
4
|
+
> app's own server function instead: `ctx.sl.counters.next()` for the next number and
|
|
5
|
+
> `ctx.sl.appRecords.claim()` to key it to a tag/person and make sure no number is ever held twice —
|
|
6
|
+
> see server-functions.md → "Uniqueness, numbering and people". (The counter here lived inside the
|
|
7
|
+
> app config doc, where an admin save could rewind it.)
|
|
8
|
+
|
|
3
9
|
> **SmartLinks SDK 2.x** (current `latest`). Install `@proveanything/smartlinks@^2`.
|
|
4
10
|
|
|
5
11
|
A **sequence** hands out a guaranteed-unique, monotonic number — `1, 2, 3, …` — and stamps it
|
package/docs/server-functions.md
CHANGED
|
@@ -182,8 +182,9 @@ interface ServerFunctionContext {
|
|
|
182
182
|
appId: string
|
|
183
183
|
|
|
184
184
|
/** SmartLinks SDK, pre-scoped to your declared `authority`. Capabilities cap what it may do.
|
|
185
|
-
* Key resources: `sl.appRecords` (per-app records), `sl.appData`
|
|
186
|
-
* data — URLs, flags,
|
|
185
|
+
* Key resources: `sl.appRecords` (per-app records, incl. race-safe `claim`), `sl.appData`
|
|
186
|
+
* (general/app-wide config + data — URLs, flags, arrays), `sl.counters` (atomic numbering),
|
|
187
|
+
* `sl.tags`, `sl.contacts`, `sl.interactions`, `sl.products`, `sl.attestations`. */
|
|
187
188
|
sl: SmartLinks
|
|
188
189
|
|
|
189
190
|
/** Capability-gated secrets (need `secrets:<name>`). `get(name)` reads this app's own secret on the
|
|
@@ -506,7 +507,11 @@ Prefer the platform primitives above over a dependency; when you do need one, pi
|
|
|
506
507
|
| Read or write SmartLinks data | `ctx.sl.*` (records, products, attestations, …) | the matching `sl:<res>:<read\|write>` |
|
|
507
508
|
| Read/write your app's own data (config, URLs, counters, arrays) | `ctx.sl.appData.get()` / `ctx.sl.appData.set({…})` — collection-scoped by default; `ctx.sl.appData.global.*` (or `{ scope:'global' }`, or declare `dataScope:'global'`) for the app-wide bucket. It's your OWN namespace, so **any** authority may read/write it | `sl:data:read` / `sl:data:write` |
|
|
508
509
|
| Read ANOTHER app's data (as the caller may see it) | `ctx.sl.app('other-app').data.get()` — caller-authority, this collection, public-filtered, read-only | `sl:data:read` |
|
|
509
|
-
| Guarantee a UNIQUE claim (
|
|
510
|
+
| Guarantee a UNIQUE claim (one entry per tag, one-per-user, idempotency key, a number nobody else holds) | `ctx.sl.appRecords.claim({ …, singletonPer, customId })` → `{ record, created, conflict? }` — see below | `sl:records:write` |
|
|
511
|
+
| Hand out the next number (tickets, order numbers) | `ctx.sl.counters.next('tickets')` — atomic, distinct per caller | `sl:data:write` |
|
|
512
|
+
| Check a tapped tag is real | `ctx.sl.tags.resolve(proofId)` → `{ id, claimSetId, code }` or `null` | `sl:tags:read` |
|
|
513
|
+
| Put a visitor's details on a contact | `ctx.sl.contacts.upsert({ email, firstName, … })` → `{ contactId, created }` (fills empty fields only) | `sl:contacts:write` |
|
|
514
|
+
| Record "they did X" | `ctx.sl.interactions.record({ interactionId, contactId, outcome, metadata })` | `sl:interactions:write` |
|
|
510
515
|
|
|
511
516
|
Notes:
|
|
512
517
|
- **Talking to the SmartLinks core is `ctx.sl`, not HTTP.** The common pattern — *validate /
|
|
@@ -525,6 +530,81 @@ Notes:
|
|
|
525
530
|
|
|
526
531
|
---
|
|
527
532
|
|
|
533
|
+
## Uniqueness, numbering and people — the race-safe primitives
|
|
534
|
+
|
|
535
|
+
A public function is called by many people at once. These primitives make the outcome right no matter
|
|
536
|
+
how the calls interleave — the **database** decides, never a read-then-write in your code.
|
|
537
|
+
|
|
538
|
+
### `appRecords.claim(fields)` — a unique create
|
|
539
|
+
|
|
540
|
+
Like `create`, but the record names a **unique key**, and it's inserted only if that key is free.
|
|
541
|
+
Otherwise you get the record already holding it back, **unchanged** (never updated):
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
const { record, created, conflict } = await ctx.sl.appRecords.claim({
|
|
545
|
+
recordType: 'entry',
|
|
546
|
+
singletonPer: 'proof', proofId: tag.id, // key 1: one live entry per tag
|
|
547
|
+
customId: String(number), // key 2: one record per number, ever
|
|
548
|
+
data: { number },
|
|
549
|
+
})
|
|
550
|
+
// created: true → you got it
|
|
551
|
+
// created: false → conflict 'singleton' (this tag already has an entry: `record` is it)
|
|
552
|
+
// or 'customId' (that number is held by someone else: take another)
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
- **`singletonPer`** — `'collection' | 'product' | 'variant' | 'batch' | 'proof'`, with its anchor id
|
|
556
|
+
(`productId`, `proofId`, …). One **live** record per key; deleting it frees the key.
|
|
557
|
+
- **`customId`** — unique per `(recordType, scopeType, scopeId, sourceSystem)`, case-insensitive, and
|
|
558
|
+
**never reused** (deleted records keep theirs). Right for numbers that must never be handed out twice.
|
|
559
|
+
- Give at least one; with both, the record is created only if both are free.
|
|
560
|
+
- Of any number of concurrent claims on one key, **exactly one** gets `created: true`.
|
|
561
|
+
|
|
562
|
+
### `counters.next(name, { start?, by? })` — the next number
|
|
563
|
+
|
|
564
|
+
An atomic counter for your app in this collection: every caller gets a distinct value, even under a
|
|
565
|
+
burst (one row, incremented in one statement). The first call returns `start` (default 1). Counters only
|
|
566
|
+
move forward and live apart from your config, so no config save can rewind one. `counters.get(name)`
|
|
567
|
+
reads the last value handed out (`null` before the first). Name a new counter to start again from `start`.
|
|
568
|
+
|
|
569
|
+
**Counter + claim = a number nobody else can ever hold.** Take `next()`, then `claim` with
|
|
570
|
+
`customId: String(n)`. If the claim says `conflict: 'customId'`, take `next()` again. A failed or raced
|
|
571
|
+
attempt only leaves a gap — never a duplicate.
|
|
572
|
+
|
|
573
|
+
### `tags.resolve(id)` — is this tap real?
|
|
574
|
+
|
|
575
|
+
A tap hands your app the virtual-proof id `<claimSetId>-<code>`. `resolve` returns
|
|
576
|
+
`{ id, claimSetId, code }` when that code exists in a claim set of **this** collection, else `null`.
|
|
577
|
+
Key records to the returned canonical `id`, and refuse `null` — otherwise anyone can invent ids.
|
|
578
|
+
|
|
579
|
+
### `contacts.upsert(details)` / `contacts.forCaller()`
|
|
580
|
+
|
|
581
|
+
`upsert` finds the contact by `email` or `phone` (one is required) or creates it, and only **fills
|
|
582
|
+
empty fields** — it never overwrites a name or field the contact already has, because a visitor's typed
|
|
583
|
+
details are unverified. It's race-safe: two simultaneous entries with one email end on one contact.
|
|
584
|
+
Returns `{ contactId, created }`. `forCaller()` is the signed-in caller's own contact (`null` when
|
|
585
|
+
anonymous). **Never return contact ids to public callers** — keep them on your records.
|
|
586
|
+
|
|
587
|
+
This is not consent. A ticked "keep me updated" box is evidence you can record (below); subscribing
|
|
588
|
+
someone to marketing is the double opt-in flow (`comms.signup`, see comms.md).
|
|
589
|
+
|
|
590
|
+
### `interactions.record(event)` — "they did X"
|
|
591
|
+
|
|
592
|
+
Records an event of one of **your app's interaction types**: create the type once in admin with
|
|
593
|
+
`SL.interactions.ensureType(collectionId, { appId, key: 'raffle-entry' })`, keep its id in your app
|
|
594
|
+
config, and pass that id. Unknown ids are refused (`INTERACTION_TYPE_NOT_FOUND`). The event runs the
|
|
595
|
+
type's effects and emits `interaction.submitted`, like any submission.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
await ctx.sl.interactions.record({
|
|
599
|
+
interactionId: config.interactionIds.entered,
|
|
600
|
+
contactId, // optional
|
|
601
|
+
outcome: 'entered',
|
|
602
|
+
metadata: { number, consent: true },
|
|
603
|
+
})
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
528
608
|
## Worked example — a public competition entry
|
|
529
609
|
|
|
530
610
|
```ts
|
|
@@ -756,6 +836,9 @@ const ctx = createFunctionTestContext({
|
|
|
756
836
|
|
|
757
837
|
const res = await submitCompetitionEntry(ctx, { method: 'POST', body: { email: 'a@b.com', answer: '42' } })
|
|
758
838
|
// ctx.sl.appRecords.create(...) throws CapabilityError unless `def` declares sl:records:write
|
|
839
|
+
// With no `sl` impl, records / counters / contacts / interactions run in memory with the platform's
|
|
840
|
+
// uniqueness rules — assert on ctx.memory. Share createFunctionTestMemory() between contexts to
|
|
841
|
+
// simulate concurrent callers; restrict ids with knownTags / knownInteractionTypes.
|
|
759
842
|
```
|
|
760
843
|
|
|
761
844
|
Pass the **`def`** (not a hand-typed capability list) so "tested" can't drift from
|
package/openapi.yaml
CHANGED
|
@@ -19218,16 +19218,113 @@ components:
|
|
|
19218
19218
|
AppDataHandle:
|
|
19219
19219
|
type: object
|
|
19220
19220
|
properties: {}
|
|
19221
|
+
AppRecordClaimResult:
|
|
19222
|
+
type: object
|
|
19223
|
+
properties:
|
|
19224
|
+
record:
|
|
19225
|
+
type: object
|
|
19226
|
+
additionalProperties: true
|
|
19227
|
+
created:
|
|
19228
|
+
type: boolean
|
|
19229
|
+
conflict:
|
|
19230
|
+
type: string
|
|
19231
|
+
enum:
|
|
19232
|
+
- singleton
|
|
19233
|
+
- customId
|
|
19234
|
+
required:
|
|
19235
|
+
- record
|
|
19236
|
+
- created
|
|
19237
|
+
ServerFunctionCounters:
|
|
19238
|
+
type: object
|
|
19239
|
+
properties: {}
|
|
19240
|
+
ResolvedTag:
|
|
19241
|
+
type: object
|
|
19242
|
+
properties:
|
|
19243
|
+
id:
|
|
19244
|
+
type: string
|
|
19245
|
+
claimSetId:
|
|
19246
|
+
type: string
|
|
19247
|
+
code:
|
|
19248
|
+
type: string
|
|
19249
|
+
required:
|
|
19250
|
+
- id
|
|
19251
|
+
- claimSetId
|
|
19252
|
+
- code
|
|
19253
|
+
ContactUpsertDetails:
|
|
19254
|
+
type: object
|
|
19255
|
+
properties:
|
|
19256
|
+
email:
|
|
19257
|
+
type: string
|
|
19258
|
+
phone:
|
|
19259
|
+
type: string
|
|
19260
|
+
firstName:
|
|
19261
|
+
type: string
|
|
19262
|
+
lastName:
|
|
19263
|
+
type: string
|
|
19264
|
+
displayName:
|
|
19265
|
+
type: string
|
|
19266
|
+
company:
|
|
19267
|
+
type: string
|
|
19268
|
+
locale:
|
|
19269
|
+
type: string
|
|
19270
|
+
timezone:
|
|
19271
|
+
type: string
|
|
19272
|
+
customFields:
|
|
19273
|
+
type: object
|
|
19274
|
+
additionalProperties:
|
|
19275
|
+
type: object
|
|
19276
|
+
additionalProperties: true
|
|
19277
|
+
ServerFunctionInteractionEvent:
|
|
19278
|
+
type: object
|
|
19279
|
+
properties:
|
|
19280
|
+
interactionId:
|
|
19281
|
+
type: string
|
|
19282
|
+
contactId:
|
|
19283
|
+
type: string
|
|
19284
|
+
outcome:
|
|
19285
|
+
type: string
|
|
19286
|
+
metadata:
|
|
19287
|
+
type: object
|
|
19288
|
+
additionalProperties: true
|
|
19289
|
+
productId:
|
|
19290
|
+
type: string
|
|
19291
|
+
proofId:
|
|
19292
|
+
type: string
|
|
19293
|
+
variantId:
|
|
19294
|
+
type: string
|
|
19295
|
+
batchId:
|
|
19296
|
+
type: string
|
|
19297
|
+
eventType:
|
|
19298
|
+
type: string
|
|
19299
|
+
required:
|
|
19300
|
+
- interactionId
|
|
19221
19301
|
ServerFunctionSl:
|
|
19222
19302
|
type: object
|
|
19223
19303
|
properties:
|
|
19224
|
-
appRecords:
|
|
19304
|
+
appRecords:
|
|
19305
|
+
type: object
|
|
19306
|
+
additionalProperties: true
|
|
19307
|
+
counters:
|
|
19308
|
+
$ref: "#/components/schemas/ServerFunctionCounters"
|
|
19309
|
+
tags:
|
|
19310
|
+
type: object
|
|
19311
|
+
additionalProperties: true
|
|
19312
|
+
contacts:
|
|
19313
|
+
type: object
|
|
19314
|
+
additionalProperties: true
|
|
19315
|
+
interactions:
|
|
19316
|
+
type: object
|
|
19317
|
+
additionalProperties: true
|
|
19225
19318
|
products: {}
|
|
19226
19319
|
attestations: {}
|
|
19227
19320
|
appData:
|
|
19228
19321
|
$ref: "#/components/schemas/AppDataHandle"
|
|
19229
19322
|
required:
|
|
19230
19323
|
- appRecords
|
|
19324
|
+
- counters
|
|
19325
|
+
- tags
|
|
19326
|
+
- contacts
|
|
19327
|
+
- interactions
|
|
19231
19328
|
- products
|
|
19232
19329
|
- attestations
|
|
19233
19330
|
- appData
|
|
@@ -23658,14 +23755,22 @@ components:
|
|
|
23658
23755
|
properties:
|
|
23659
23756
|
email:
|
|
23660
23757
|
type: string
|
|
23758
|
+
templateId:
|
|
23759
|
+
type: string
|
|
23661
23760
|
topics:
|
|
23662
23761
|
type: array
|
|
23663
23762
|
items:
|
|
23664
23763
|
type: string
|
|
23665
23764
|
firstName:
|
|
23666
23765
|
type: string
|
|
23766
|
+
fields:
|
|
23767
|
+
type: object
|
|
23768
|
+
additionalProperties: true
|
|
23769
|
+
appId:
|
|
23770
|
+
type: string
|
|
23667
23771
|
required:
|
|
23668
23772
|
- email
|
|
23773
|
+
- templateId
|
|
23669
23774
|
CommsSignupResponse:
|
|
23670
23775
|
type: object
|
|
23671
23776
|
properties:
|