@proveanything/smartlinks 2.1.15 → 2.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ClaimSetScope, ClaimSetRange, CreateClaimSetRangeRequest, UpdateClaimSetRangeRequest, ClaimSetRangeTagsPage, ClaimSetRangeTag, ClaimSetRangeNextTag, ImportClaimSetRangeUidsRequest, ImportClaimSetRangeUidsResponse, AssignClaimSetRangeRequest, AssignClaimSetRangeResponse, ClaimSetRangeFile, UpdateClaimDataRequest, AssignClaimsRequest, CreateClaimSetTagRequest, CreateClaimSetTagResponse, CreateClaimSetRequest, ImportClaimSetTagsRequest, ImportClaimSetTagsResponse } from "../types/index.js";
1
+ import { ClaimSetScope, ClaimSetRange, CreateClaimSetRangeRequest, UpdateClaimSetRangeRequest, ClaimSetRangeTagsPage, ClaimSetRangeTag, ClaimSetRangeNextTag, ImportClaimSetRangeUidsRequest, ImportClaimSetRangeUidsResponse, AssignClaimSetRangeRequest, AddClaimSetRangeMemberRequest, AddClaimSetRangeMemberResponse, CloseClaimSetRangeResponse, AssignClaimSetRangeResponse, ClaimSetRangeFile, UpdateClaimDataRequest, AssignClaimsRequest, CreateClaimSetTagRequest, CreateClaimSetTagResponse, CreateClaimSetRequest, ImportClaimSetTagsRequest, ImportClaimSetTagsResponse } from "../types/index.js";
2
2
  export declare namespace claimSet {
3
3
  /**
4
4
  * Get all claim sets.
@@ -106,6 +106,14 @@ export declare namespace claimSet {
106
106
  function update(scope: ClaimSetScope, claimSetId: string, rangeId: string, params: UpdateClaimSetRangeRequest): Promise<ClaimSetRange>;
107
107
  /** Finish minting a range that was interrupted. */
108
108
  function resume(scope: ClaimSetScope, claimSetId: string, rangeId: string): Promise<ClaimSetRange>;
109
+ /** Close an open range: it becomes a range with the quantity it reached (removed if nothing was minted). */
110
+ function close(scope: ClaimSetScope, claimSetId: string, rangeId: string): Promise<CloseClaimSetRangeResponse>;
111
+ /** Put an existing tag of the set into a group (by code or chip UID), assigning it if a collection is given. */
112
+ function addMember(scope: ClaimSetScope, claimSetId: string, groupId: string, params: AddClaimSetRangeMemberRequest): Promise<AddClaimSetRangeMemberResponse>;
113
+ /** Take a tag out of a group. Its assignment is left as it is. */
114
+ function removeMember(scope: ClaimSetScope, claimSetId: string, groupId: string, code: string): Promise<{
115
+ range: ClaimSetRange;
116
+ }>;
109
117
  /** The range's tags in index order, a page at a time (keys: owners/admins only). */
110
118
  function tags(scope: ClaimSetScope, claimSetId: string, rangeId: string, options?: {
111
119
  from?: number;
@@ -190,6 +190,21 @@ export var claimSet;
190
190
  return post(`${rangesBase(scope, claimSetId)}/${encodeURIComponent(rangeId)}/resume`, {});
191
191
  }
192
192
  ranges.resume = resume;
193
+ /** Close an open range: it becomes a range with the quantity it reached (removed if nothing was minted). */
194
+ async function close(scope, claimSetId, rangeId) {
195
+ return post(`${rangesBase(scope, claimSetId)}/${encodeURIComponent(rangeId)}/close`, {});
196
+ }
197
+ ranges.close = close;
198
+ /** Put an existing tag of the set into a group (by code or chip UID), assigning it if a collection is given. */
199
+ async function addMember(scope, claimSetId, groupId, params) {
200
+ return post(`${rangesBase(scope, claimSetId)}/${encodeURIComponent(groupId)}/members`, params);
201
+ }
202
+ ranges.addMember = addMember;
203
+ /** Take a tag out of a group. Its assignment is left as it is. */
204
+ async function removeMember(scope, claimSetId, groupId, code) {
205
+ return del(`${rangesBase(scope, claimSetId)}/${encodeURIComponent(groupId)}/members/${encodeURIComponent(code)}`);
206
+ }
207
+ ranges.removeMember = removeMember;
193
208
  /** The range's tags in index order, a page at a time (keys: owners/admins only). */
194
209
  async function tags(scope, claimSetId, rangeId, options = {}) {
195
210
  const q = new URLSearchParams();
@@ -0,0 +1,10 @@
1
+ import { ClientErrorReport } from "../types/index.js";
2
+ /**
3
+ * Report an error an app saw, into the platform's one error log (the same BigQuery error-events table the
4
+ * server writes to). Best effort: never throws into the flow that failed.
5
+ */
6
+ export declare namespace errors {
7
+ function report(event: ClientErrorReport): Promise<{
8
+ kept: boolean;
9
+ }>;
10
+ }
@@ -0,0 +1,17 @@
1
+ import { post } from "../http.js";
2
+ /**
3
+ * Report an error an app saw, into the platform's one error log (the same BigQuery error-events table the
4
+ * server writes to). Best effort: never throws into the flow that failed.
5
+ */
6
+ export var errors;
7
+ (function (errors) {
8
+ async function report(event) {
9
+ try {
10
+ return await post(`/admin/log/error`, event);
11
+ }
12
+ catch (_a) {
13
+ return { kept: false };
14
+ }
15
+ }
16
+ errors.report = report;
17
+ })(errors || (errors = {}));
@@ -11,6 +11,7 @@ export { form } from "./form.js";
11
11
  export { authKit } from "./authKit.js";
12
12
  export { claimSet } from "./claimSet.js";
13
13
  export { partner } from "./partner.js";
14
+ export { errors } from "./errors.js";
14
15
  export { crate } from "./crate.js";
15
16
  export { batch } from "./batch.js";
16
17
  export { variant } from "./variant.js";
package/dist/api/index.js CHANGED
@@ -13,6 +13,7 @@ export { form } from "./form.js";
13
13
  export { authKit } from "./authKit.js";
14
14
  export { claimSet } from "./claimSet.js";
15
15
  export { partner } from "./partner.js";
16
+ export { errors } from "./errors.js";
16
17
  export { crate } from "./crate.js";
17
18
  export { batch } from "./batch.js";
18
19
  export { variant } from "./variant.js";
@@ -1,4 +1,4 @@
1
- import { Partner, PartnerClaimSet, CreatePartnerClaimSetRequest, PartnerTagHit } from "../types/index.js";
1
+ import { Partner, PartnerClaimSet, CreatePartnerClaimSetRequest, PartnerTagHit, ClientErrorEvent } from "../types/index.js";
2
2
  /**
3
3
  * Partners: organisations above collections (FanConnect, Zenith Coins, …) that own NFC claim sets and
4
4
  * assign their tags into collections their people administer. Partners and their members are set up by
@@ -21,4 +21,10 @@ export declare namespace partner {
21
21
  uid?: string;
22
22
  code?: string;
23
23
  }): Promise<PartnerTagHit[]>;
24
+ /**
25
+ * What phones reported about one chip (errors.report with entityType 'tag'), newest first: a tag that
26
+ * failed to program or register, with the host's NFC log in context. For a chip in the partner's sets,
27
+ * or attempts that named one of them.
28
+ */
29
+ function tagAttempts(partnerId: string, uid: string): Promise<ClientErrorEvent[]>;
24
30
  }
@@ -42,4 +42,13 @@ export var partner;
42
42
  return request(`/admin/partner/${encodeURIComponent(partnerId)}/tags/lookup?${q}`);
43
43
  }
44
44
  partner.lookupTags = lookupTags;
45
+ /**
46
+ * What phones reported about one chip (errors.report with entityType 'tag'), newest first: a tag that
47
+ * failed to program or register, with the host's NFC log in context. For a chip in the partner's sets,
48
+ * or attempts that named one of them.
49
+ */
50
+ async function tagAttempts(partnerId, uid) {
51
+ return request(`/admin/partner/${encodeURIComponent(partnerId)}/tags/attempts?uid=${encodeURIComponent(uid)}`);
52
+ }
53
+ partner.tagAttempts = tagAttempts;
45
54
  })(partner || (partner = {}));
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.15 | Generated: 2026-11-09T15:17:42.472Z
3
+ Version: 2.1.17 | Generated: 2026-11-11T09:04:21.796Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -144,6 +144,7 @@ The Smartlinks SDK is organized into the following namespaces:
144
144
  - **bookings** - Functions for bookings operations
145
145
  - **config** - Functions for config operations
146
146
  - **containers** - Functions for containers operations
147
+ - **errors** - Functions for errors operations
147
148
  - **facets** - Functions for facets operations
148
149
  - **functions** - Functions for functions operations
149
150
  - **http** - Functions for http operations
@@ -2838,11 +2839,46 @@ interface ServerFunctionBookings {
2838
2839
  }
2839
2840
  ```
2840
2841
 
2842
+ **AppHistoryEvent** (interface)
2843
+ ```typescript
2844
+ interface AppHistoryEvent {
2845
+ id: string;
2846
+ at: string;
2847
+ type: string;
2848
+ entityId: string | null;
2849
+ data: Record<string, any>;
2850
+ actorType: 'user' | 'contact' | null;
2851
+ actorId: string | null;
2852
+ }
2853
+ ```
2854
+
2855
+ **AppHistoryQuery** (interface)
2856
+ ```typescript
2857
+ interface AppHistoryQuery {
2858
+ types?: string | string[];
2859
+ entityId?: string;
2860
+ data?: Record<string, string | number | boolean | Array<string | number | boolean>>;
2861
+ from?: string;
2862
+ to?: string;
2863
+ limit?: number;
2864
+ }
2865
+ ```
2866
+
2841
2867
  **ServerFunctionSl** (interface)
2842
2868
  ```typescript
2843
2869
  interface ServerFunctionSl {
2844
2870
  bookings: ServerFunctionBookings;
2845
- appRecords: { claim(fields: Record<string, any>): Promise<AppRecordClaimResult>; [method: string]: any };
2871
+ appRecords: {
2872
+ claim(fields: Record<string, any>): Promise<AppRecordClaimResult>;
2873
+ adjust(recordId: string, by: Record<string, number>, opts?: { atLeast?: Record<string, number>; atMost?: Record<string, number> }): Promise<any>;
2874
+ aggregate(params?: import('./appObjects').AggregateRequest): Promise<import('./appObjects').AggregateResponse & Record<string, unknown>>;
2875
+ [method: string]: any;
2876
+ };
2877
+ transaction(ops: RecordTransactionOp[], opts?: { idempotencyKey?: string }): Promise<{ results: any[]; replayed: boolean }>;
2878
+ history: {
2879
+ log(type: string, data?: Record<string, any>, opts?: { entityId?: string }): Promise<{ id: string }>;
2880
+ query(params?: AppHistoryQuery): Promise<AppHistoryEvent[]>;
2881
+ };
2846
2882
  counters: ServerFunctionCounters;
2847
2883
  tags: { resolve(id: string): Promise<ResolvedTag | null> };
2848
2884
  contacts: {
@@ -3298,6 +3334,8 @@ interface GetCollectionWidgetsOptions {
3298
3334
 
3299
3335
  **AppFunctionAuthority** = `'caller' | 'collection'`
3300
3336
 
3337
+ **RecordTransactionOp** = ``
3338
+
3301
3339
  ### appObjects
3302
3340
 
3303
3341
  **PaginatedResponse<T>** (interface)
@@ -3324,9 +3362,12 @@ interface AggregateRequest {
3324
3362
  created_at?: { gte?: string; lte?: string }
3325
3363
  closed_at?: '__notnull__' | { gte?: string; lte?: string } // cases only
3326
3364
  expires_at?: { lte?: string } // records only
3365
+ updated_at?: { gte?: string; lte?: string } // records only
3366
+ data?: Record<string, string | number | boolean | Array<string | number | boolean>>
3367
+ dataRange?: Record<string, { gte?: number; gt?: number; lte?: number; lt?: number }>
3327
3368
  }
3328
- groupBy?: string[] // see per-resource allowed values
3329
- metrics?: string[] // see per-resource allowed values below
3369
+ groupBy?: string[]
3370
+ metrics?: string[]
3330
3371
  timeSeriesField?: string // e.g. 'created_at'
3331
3372
  timeSeriesInterval?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
3332
3373
  }
@@ -3335,7 +3376,7 @@ interface AggregateRequest {
3335
3376
  **AggregateResponse** (interface)
3336
3377
  ```typescript
3337
3378
  interface AggregateResponse {
3338
- groups?: ({ count: number } & Record<string, unknown>)[]
3379
+ groups?: ({ count?: number } & Record<string, unknown>)[]
3339
3380
  timeSeries?: ({ bucket: string; count: number } & Record<string, unknown>)[]
3340
3381
  count?: number
3341
3382
  avg_close_time_seconds?: number
@@ -5775,10 +5816,11 @@ interface ClaimSetRange {
5775
5816
  id: string
5776
5817
  claimSetId: string
5777
5818
  namespace: string
5819
+ kind: ClaimSetRangeKind
5778
5820
  name: string
5779
5821
  notes: string | null
5780
- startIndex: number
5781
- endIndex: number
5822
+ startIndex: number | null
5823
+ endIndex: number | null
5782
5824
  quantity: number
5783
5825
  tagType: ClaimSetTagType
5784
5826
  fulfilment: 'printer' | 'sidekick'
@@ -5801,8 +5843,9 @@ interface ClaimSetRange {
5801
5843
  **CreateClaimSetRangeRequest** (interface)
5802
5844
  ```typescript
5803
5845
  interface CreateClaimSetRangeRequest {
5846
+ kind?: ClaimSetRangeKind
5804
5847
  name: string
5805
- quantity: number
5848
+ quantity?: number
5806
5849
  intendedCollectionId?: string
5807
5850
  intendedProductId?: string
5808
5851
  intendedBatchId?: string
@@ -5837,6 +5880,8 @@ interface ClaimSetRangeTag {
5837
5880
  productId: string | null
5838
5881
  batchId: string | null
5839
5882
  proofId: string | null
5883
+ rangeId: string | null
5884
+ groupId: string | null
5840
5885
  programmedAt: string | null
5841
5886
  scans: number
5842
5887
  }
@@ -5850,6 +5895,29 @@ interface ClaimSetRangeTagsPage {
5850
5895
  }
5851
5896
  ```
5852
5897
 
5898
+ **AddClaimSetRangeMemberRequest** (interface)
5899
+ ```typescript
5900
+ interface AddClaimSetRangeMemberRequest {
5901
+ code?: string
5902
+ uid?: string
5903
+ collectionId?: string
5904
+ productId?: string
5905
+ batchId?: string
5906
+ data?: Record<string, unknown>
5907
+ clear?: boolean
5908
+ }
5909
+ ```
5910
+
5911
+ **AddClaimSetRangeMemberResponse** (interface)
5912
+ ```typescript
5913
+ interface AddClaimSetRangeMemberResponse {
5914
+ tag: ClaimSetRangeTag
5915
+ range: ClaimSetRange
5916
+ assigned: { changed: number; skipped: Array<{ code: string; index: number | null; reason: 'other_range' | 'has_proof' | 'assigned_elsewhere'; collectionId?: string }> }
5917
+ movedFrom: string | null
5918
+ }
5919
+ ```
5920
+
5853
5921
  **ImportClaimSetRangeUidsRequest** (interface)
5854
5922
  ```typescript
5855
5923
  interface ImportClaimSetRangeUidsRequest {
@@ -5939,6 +6007,7 @@ interface PartnerTagHit {
5939
6007
  set: PartnerClaimSet | null
5940
6008
  tag: ClaimSetRangeTag & { url: string | null }
5941
6009
  range: ClaimSetRange | null
6010
+ group: ClaimSetRange | null
5942
6011
  }
5943
6012
  ```
5944
6013
 
@@ -5958,10 +6027,56 @@ type ClaimSetScope = { collectionId: string }
5958
6027
 
5959
6028
  **ClaimSetTagType** = `'ntag424' | 'ntag21x' | 'qr'`
5960
6029
 
5961
- **ClaimSetRangeStatus** = `'minting' | 'minted' | 'sent' | 'received' | 'assigned'`
6030
+ **ClaimSetRangeKind** = `'range' | 'open' | 'group'`
6031
+
6032
+ **ClaimSetRangeStatus** = `'minting' | 'minted' | 'sent' | 'received' | 'assigned' | 'open' | 'group'`
6033
+
6034
+ **CloseClaimSetRangeResponse** = `ClaimSetRange | { id: string`
5962
6035
 
5963
6036
  **ClaimSetImportResult** = ``
5964
6037
 
6038
+ ### clientLog
6039
+
6040
+ **ClientErrorReport** (interface)
6041
+ ```typescript
6042
+ interface ClientErrorReport {
6043
+ app?: string
6044
+ appVersion?: string
6045
+ collectionId?: string
6046
+ operation?: string
6047
+ severity?: 'debug' | 'info' | 'warning' | 'error' | 'critical'
6048
+ errorCode?: string
6049
+ errorType?: string
6050
+ message?: string
6051
+ stack?: string
6052
+ entityType?: string
6053
+ entityId?: string
6054
+ labels?: Record<string, unknown>
6055
+ context?: Record<string, unknown>
6056
+ refId?: string
6057
+ correlationId?: string
6058
+ }
6059
+ ```
6060
+
6061
+ **ClientErrorEvent** (interface)
6062
+ ```typescript
6063
+ interface ClientErrorEvent {
6064
+ id: string
6065
+ time: string
6066
+ event_category: string
6067
+ event_action: string | null
6068
+ log_level: string
6069
+ error_code: string | null
6070
+ error_message: string | null
6071
+ service_name: string | null
6072
+ service_version: string | null
6073
+ collection_id: string | null
6074
+ user_id: string | null
6075
+ labels: Record<string, unknown> | null
6076
+ context: Record<string, unknown> | null
6077
+ }
6078
+ ```
6079
+
5965
6080
  ### collection
5966
6081
 
5967
6082
  **Collection** (interface)
@@ -12464,6 +12579,15 @@ Name, notes, order details, or a stage move (sent to the printer, received).
12464
12579
  **resume**(scope: ClaimSetScope, claimSetId: string, rangeId: string) → `Promise<ClaimSetRange>`
12465
12580
  Finish minting a range that was interrupted.
12466
12581
 
12582
+ **close**(scope: ClaimSetScope, claimSetId: string, rangeId: string) → `Promise<CloseClaimSetRangeResponse>`
12583
+ Close an open range: it becomes a range with the quantity it reached (removed if nothing was minted).
12584
+
12585
+ **addMember**(scope: ClaimSetScope, claimSetId: string, groupId: string, params: AddClaimSetRangeMemberRequest) → `Promise<AddClaimSetRangeMemberResponse>`
12586
+ Put an existing tag of the set into a group (by code or chip UID), assigning it if a collection is given.
12587
+
12588
+ **removeMember**(scope: ClaimSetScope, claimSetId: string, groupId: string, code: string) → `Promise<`
12589
+ Take a tag out of a group. Its assignment is left as it is.
12590
+
12467
12591
  **tags**(scope: ClaimSetScope, claimSetId: string, rangeId: string,
12468
12592
  options: { from?: number; limit?: number; keys?: boolean } = {}) → `Promise<ClaimSetRangeTagsPage>`
12469
12593
  The range's tags in index order, a page at a time (keys: owners/admins only).
@@ -12790,6 +12914,10 @@ Update a crate for a collection (admin only). ```typescript const updated = awai
12790
12914
  crateId: string) → `Promise<DeleteCrateResponse>`
12791
12915
  Delete a crate for a collection (admin only). This performs a soft delete. ```typescript await crate.remove('coll_123', 'crate_abc123') ```
12792
12916
 
12917
+ ### errors
12918
+
12919
+ **report**(event: ClientErrorReport) → `Promise<`
12920
+
12793
12921
  ### facets
12794
12922
 
12795
12923
  **list**(collectionId: string,
@@ -13422,6 +13550,9 @@ A new claim set for the partner, on one of its tap domains (owners and admins).
13422
13550
  **lookupTags**(partnerId: string, query: { uid?: string; code?: string }) → `Promise<PartnerTagHit[]>`
13423
13551
  Find a tag across the partner's sets by its chip UID (8 or 14 hex) or its code (printed or bare). Never returns keys.
13424
13552
 
13553
+ **tagAttempts**(partnerId: string, uid: string) → `Promise<ClientErrorEvent[]>`
13554
+ What phones reported about one chip (errors.report with entityType 'tag'), newest first: a tag that failed to program or register, with the host's NFC log in context. For a chip in the partner's sets, or attempts that named one of them.
13555
+
13425
13556
  ### podcast
13426
13557
 
13427
13558
  **generate**(collectionId: string,
@@ -807,6 +807,29 @@ await app.records.upsert(collectionId, appId, {
807
807
  // customId is stored for later reverse-lookup via ?customId=&sourceSystem=
808
808
  ```
809
809
 
810
+ ### Totals over data (sum, average, min, max)
811
+
812
+ Records also total the numbers in their `data`, grouped by top-level data keys, with data filters and
813
+ numeric ranges. Stock per location, forecast per SKU:
814
+
815
+ ```typescript
816
+ const stock = await app.records.aggregate(collectionId, appId, {
817
+ groupBy: ['data.location'], // up to 5: columns or 'data.<key>'
818
+ metrics: ['count', 'sum:qty', 'min:qty'], // count; sum: / avg: / min: / max: + a data key
819
+ filters: {
820
+ record_type: 'bin',
821
+ data: { warehouse: ['north', 'south'] }, // exact match, one value or any of several
822
+ dataRange: { qty: { gt: 0 } }, // gte / gt / lte / lt, numbers only
823
+ updated_at: { gte: '2026-11-01T00:00:00Z' },
824
+ },
825
+ }, true)
826
+ // stock.groups → [{ 'data.location': 'A1', count: 12, sum_qty: 340, min_qty: 2 }, …]
827
+ ```
828
+
829
+ Values that aren't numbers are left out of sums and averages. To CHANGE numbers safely (stock moves,
830
+ counts), use a server function: `ctx.sl.appRecords.adjust` and `ctx.sl.transaction`
831
+ ([server-functions.md](server-functions.md)).
832
+
810
833
  ### Counts by Record Type
811
834
 
812
835
  `aggregate()` returns counts grouped by `record_type` in a single round-trip — no separate endpoint needed:
@@ -514,6 +514,10 @@ Prefer the platform primitives above over a dependency; when you do need one, pi
514
514
  | 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` |
515
515
  | 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` |
516
516
  | Hand out the next number (tickets, order numbers) | `ctx.sl.counters.next('tickets')` — atomic, distinct per caller | `sl:data:write` |
517
+ | Change numbers in a record (stock, counts, balances) without losing updates | `ctx.sl.appRecords.adjust(id, { qty: -5 }, { atLeast: { qty: 0 } })` — in place, conditions checked after the change | `sl:records:write` |
518
+ | Several record writes that land together or not at all (a stock move) | `ctx.sl.transaction([ops], { idempotencyKey })` — see below | `sl:records:write` |
519
+ | Totals: stock per location, forecast per SKU | `ctx.sl.appRecords.aggregate({ groupBy: ['data.location'], metrics: ['sum:qty'] })` | `sl:records:read` |
520
+ | Keep a history of what happened (stock moves, counts) without growing your records | `ctx.sl.history.log('stock.moved', {…})`, or a `log` op in a transaction; read with `ctx.sl.history.query({…})` | `sl:history:write` / `sl:history:read` |
517
521
  | Check a tapped tag is real | `ctx.sl.tags.resolve(proofId)` → `{ id, claimSetId, code }` or `null` | `sl:tags:read` |
518
522
  | Put a visitor's details on a contact | `ctx.sl.contacts.upsert({ email, firstName, … })` → `{ contactId, created }` (fills empty fields only) | `sl:contacts:write` |
519
523
  | Record "they did X" | `ctx.sl.interactions.record({ interactionId, contactId, outcome, metadata })` | `sl:interactions:write` |
@@ -579,6 +583,90 @@ reads the last value handed out (`null` before the first). Name a new counter to
579
583
  `customId: String(n)`. If the claim says `conflict: 'customId'`, take `next()` again. A failed or raced
580
584
  attempt only leaves a gap — never a duplicate.
581
585
 
586
+ ### `appRecords.adjust(id, by, { atLeast?, atMost? })` — numbers that never lose an update
587
+
588
+ Adds to numbers in a record's `data` in ONE statement, so twenty scanners taking from the same bin at
589
+ once each land: no read-then-write, no lost counts. A missing value counts as 0. `atLeast` / `atMost` are
590
+ checked on the values **after** the change, inside the same statement:
591
+
592
+ ```ts
593
+ // Take 5 from bin A — refused (nothing changes) if it would go below zero.
594
+ await ctx.sl.appRecords.adjust(binA, { qty: -5 }, { atLeast: { qty: 0 } })
595
+ ```
596
+
597
+ Refusals carry `code`: `NOT_FOUND` (not this app's record in this collection, or deleted), `NOT_A_NUMBER`
598
+ (the key holds something else), `CONDITION_FAILED` (409, with the `current` values). Only top-level data
599
+ keys; a key named only in a condition is checked as it stands.
600
+
601
+ ### `transaction(ops, { idempotencyKey? })` — all or nothing
602
+
603
+ Several writes as ONE transaction: every op lands, or none does. The classic: take stock out of bin A,
604
+ put it on order line B, and log the move.
605
+
606
+ ```ts
607
+ const { results, replayed } = await ctx.sl.transaction([
608
+ { op: 'adjust', recordId: binA, by: { qty: -5 }, atLeast: { qty: 0 } },
609
+ { op: 'adjust', recordId: orderLineB, by: { picked: 5 } },
610
+ { op: 'create', record: { recordType: 'movement', data: { from: binA, to: orderLineB, qty: 5 } } },
611
+ ], { idempotencyKey: scanId })
612
+ ```
613
+
614
+ - Ops: `create` (`record`), `update` (`recordId`, `fields`), `adjust` (`recordId`, `by`, `atLeast?`,
615
+ `atMost?`), `delete` (`recordId`), `log` (`type`, `data`, `entityId?`: a history event, see below).
616
+ Up to 50, run in order. This app's records in this collection only.
617
+ - A failing op rolls everything back. The error says which op (`opIndex`) and why (its `code`).
618
+ - Change events and realtime updates go out only after the transaction commits, never for a rollback.
619
+ - **`idempotencyKey`** ("this was already done"): recorded in the same commit, with what was written
620
+ (ids only). The same key again within 7 days doesn't write; it returns those records as they are now,
621
+ with `replayed: true`. So a handheld that retries after a dropped signal never moves stock twice. Two
622
+ calls with one key at the same moment: one commits, the other replays it. A transaction that failed
623
+ leaves its key free, so a retry can still succeed.
624
+ - Use the scan's or the request's own id as the key (one key = one intended change).
625
+ - Browser code never writes stock directly: a warehouse screen calls your function.
626
+
627
+ ### `history` — what happened, kept without growing your records
628
+
629
+ Keep **current state** in records (a bin's quantity, an order's status) and **what happened** in the app's
630
+ history: append-only events in SmartLinks' analytics store, kept for good, cheap however many there
631
+ are. Picking every order line for years would bloat records; as history it costs nothing operational.
632
+
633
+ ```ts
634
+ // In a transaction: commits with the stock change, or not at all.
635
+ await ctx.sl.transaction([
636
+ { op: 'adjust', recordId: binA, by: { qty: -5 }, atLeast: { qty: 0 } },
637
+ { op: 'log', type: 'stock.moved', entityId: binA, data: { sku: 'EDP-50', from: 'A1', to: orderId, qty: 5 } },
638
+ ], { idempotencyKey: scanId })
639
+
640
+ // On its own:
641
+ await ctx.sl.history.log('count.taken', { bin: 'A1', counted: 40, expected: 42 }, { entityId: binA })
642
+
643
+ // Reading back: this app's events in this collection, newest first (default last 30 days, max 400).
644
+ const moves = await ctx.sl.history.query({ types: 'stock.moved', entityId: binA, limit: 20 })
645
+ // → [{ id, at, type, entityId, data, actorType, actorId }]
646
+ ```
647
+
648
+ - `type`: lowercase letters, digits and `. _ -` (`stock.moved`). `data`: up to 32 KB, so **log a summary**:
649
+ one line for "5 of EDP-50", not five; one line for an RFID count, not one per tag.
650
+ - Delivery takes seconds and is retried until it lands; a logged event is never lost. A query reads the
651
+ analytics store, so expect about a second: right for a history panel, not for a hot path.
652
+ - The person who called your function is recorded as the actor.
653
+
654
+ ### `appRecords.aggregate(params)` — totals
655
+
656
+ Counts and sums over your app's records, worked out in the database:
657
+
658
+ ```ts
659
+ await ctx.sl.appRecords.aggregate({
660
+ groupBy: ['data.location', 'data.sku'], // columns, or top-level data keys
661
+ metrics: ['count', 'sum:qty'], // sum: / avg: / min: / max: over data numbers
662
+ filters: { record_type: 'bin', data: { warehouse: 'north' }, dataRange: { qty: { gt: 0 } } },
663
+ })
664
+ // → { groups: [{ 'data.location': 'A1', 'data.sku': 'EDP-50', count: 3, sum_qty: 42 }, …] }
665
+ ```
666
+
667
+ Non-numbers are ignored by `sum`/`avg`/`min`/`max`. The same request works from the browser through
668
+ `app.records.aggregate` (admin) — see [app-objects.md](app-objects.md).
669
+
582
670
  ### `tags.resolve(id)` — is this tap real?
583
671
 
584
672
  A tap hands your app the virtual-proof id `<claimSetId>-<code>`. `resolve` returns
@@ -55,6 +55,35 @@ tag without a chip, with its URL and key; write it, then `ranges.recordTag(scope
55
55
 
56
56
  To find where a chip or code went, `partner.lookupTags(partner.id, { uid })` or `{ code }` searches every set the partner owns and returns each hit's set, tag and range, never a key.
57
57
 
58
+ ## Open ranges and groups
59
+
60
+ A range's count is fixed when it is minted. Two phone-side jobs don't know their count in advance, so a range
61
+ has a `kind`:
62
+
63
+ - **`open`**: blank tags programmed on Sidekick one at a time. `ranges.create(scope, set.id, { kind: 'open', name })`
64
+ reserves only a start index; each `ranges.next()` hands back a free minted code or mints one more, so
65
+ `quantity`/`endIndex` grow as tags are written (`remaining` is null). One open range per set, and nothing can
66
+ be minted behind it (`409 RANGE_OPEN`) until `ranges.close(scope, set.id, range.id)` turns it into an ordinary
67
+ range (an open range that minted nothing is removed instead).
68
+ - **`group`**: tags that already exist, added by tapping, assigned by hand, and moved together later. Assigners may
69
+ make one: `ranges.create(scope, set.id, { kind: 'group', name, intendedCollectionId? })`. Each tap goes through
70
+ `ranges.addMember(scope, set.id, group.id, { code | uid, collectionId, productId? })`, which stamps the tag with
71
+ the group and assigns it with the single-tag rules; `assigned.skipped` says when a tag was left alone (claimed,
72
+ or in a collection you don't administer). `ranges.assign(scope, set.id, group.id, { collectionId })` moves every
73
+ member in one go; `ranges.removeMember` takes one out. Groups have no printer file, instructions, import or
74
+ `next` (`400 NOT_A_RANGE`). Its tags must be codes of the group's set; a chip that is a code elsewhere is
75
+ `409 OTHER_SET`.
76
+
77
+ Status reads `open` or `group` for these (and `assigned` once every member of a group is assigned).
78
+
79
+ ## When a tag fails on the phone
80
+
81
+ `errors.report({ app: 'sidekick', operation: 'tag.program', errorCode: 'TAG_LOST', message, entityType: 'tag',
82
+ entityId: chipUid, labels: { claimSetId, code, failedStep }, context: { steps, timings, hostLog } })` keeps what
83
+ the phone saw in the platform's one error log (never a key: fields that look like one are dropped).
84
+ `partner.tagAttempts(partner.id, chipUid)` and the platform's tag lookup read it back, newest first, so a
85
+ failing chip can be understood by its UID without the phone's console.
86
+
58
87
  ## The printer file
59
88
 
60
89
  One row per tag, in index order. The manufacturer fills in `UID` and sends the same file back; rows are