@proveanything/smartlinks 2.1.16 → 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.
@@ -77,7 +77,7 @@ function allowsSl(p, resource, op) {
77
77
  }
78
78
  /** A fresh in-memory store — pass the same one to several test contexts to simulate concurrent callers. */
79
79
  export function createFunctionTestMemory() {
80
- return { records: [], counters: {}, contacts: [], interactions: [], bookings: createBookingsMemory() };
80
+ return { records: [], counters: {}, contacts: [], interactions: [], bookings: createBookingsMemory(), transactions: {}, history: [] };
81
81
  }
82
82
  const stub = (method, args) => ({ __stub: true, method, args });
83
83
  /**
@@ -101,6 +101,9 @@ export function createFunctionTestContext(opts) {
101
101
  return fn ? fn(...args) : stub(`${resource}.${name}`, args);
102
102
  };
103
103
  const mem = opts.memory || createFunctionTestMemory();
104
+ // A memory built before history / transactions existed still works.
105
+ mem.history = mem.history || [];
106
+ mem.transactions = mem.transactions || {};
104
107
  const ar = impl.appRecords || {};
105
108
  const pr = impl.products || {};
106
109
  const at = impl.attestations || {};
@@ -147,6 +150,60 @@ export function createFunctionTestContext(opts) {
147
150
  return null; Object.assign(r, fields); return Object.assign({}, r); },
148
151
  delete: async (id) => { const r = mem.records.find((x) => x.id === id); if (r)
149
152
  r.deletedAt = new Date().toISOString(); return {}; },
153
+ // Numbers in data changed in place, with atLeast / atMost checked after the change (as the platform).
154
+ adjust: async (id, by = {}, opts = {}) => {
155
+ var _a, _b;
156
+ const r = mem.records.find((x) => x.id === id && !x.deletedAt);
157
+ if (!r)
158
+ throw Object.assign(new Error('Record not found'), { code: 'NOT_FOUND', statusCode: 404 });
159
+ const data = r.data || {};
160
+ const keys = [...new Set([...Object.keys(by), ...Object.keys(opts.atLeast || {}), ...Object.keys(opts.atMost || {})])];
161
+ const bad = keys.find((k) => data[k] != null && typeof data[k] !== 'number');
162
+ if (bad)
163
+ throw Object.assign(new Error(`data.${bad} isn't a number, so it can't be adjusted`), { code: 'NOT_A_NUMBER', statusCode: 400, key: bad });
164
+ const next = {};
165
+ for (const k of keys)
166
+ next[k] = ((_a = data[k]) !== null && _a !== void 0 ? _a : 0) + ((_b = by[k]) !== null && _b !== void 0 ? _b : 0);
167
+ const broken = Object.entries(opts.atLeast || {}).some(([k, n]) => next[k] < n) || Object.entries(opts.atMost || {}).some(([k, n]) => next[k] > n);
168
+ if (broken)
169
+ throw Object.assign(new Error('The change would break its condition (atLeast / atMost)'), { code: 'CONDITION_FAILED', statusCode: 409, current: Object.fromEntries(keys.map((k) => { var _a; return [k, (_a = data[k]) !== null && _a !== void 0 ? _a : 0]; })) });
170
+ r.data = Object.assign(Object.assign({}, data), Object.fromEntries(Object.keys(by).map((k) => [k, next[k]])));
171
+ return Object.assign({}, r);
172
+ },
173
+ // count / sum: / avg: / min: / max: over data numbers, grouped by top-level fields or data keys
174
+ // ('data.location'); filters.data (exact) as the platform. A simple stand-in for tests.
175
+ aggregate: async (params = {}) => {
176
+ const { groupBy = [], metrics = ['count'], filters = {} } = params || {};
177
+ const rows = mem.records.filter((r) => !r.deletedAt &&
178
+ (!filters.record_type || r.recordType === filters.record_type) &&
179
+ (!filters.status || r.status === filters.status) &&
180
+ Object.entries(filters.data || {}).every(([k, v]) => (Array.isArray(v) ? v.map(String) : [String(v)]).includes(String((r.data || {})[k]))));
181
+ const field = (r, f) => (f.startsWith('data.') ? (r.data || {})[f.slice(5)] : r[f === 'record_type' ? 'recordType' : f]);
182
+ const measure = (set) => {
183
+ const out = {};
184
+ for (const m of metrics) {
185
+ if (m === 'count') {
186
+ out.count = set.length;
187
+ continue;
188
+ }
189
+ const [fn, key] = String(m).split(':');
190
+ const nums = set.map((r) => (r.data || {})[key]).filter((v) => typeof v === 'number');
191
+ const sum = nums.reduce((a, b) => a + b, 0);
192
+ out[`${fn}_${key}`] = !nums.length ? null : fn === 'sum' ? sum : fn === 'avg' ? sum / nums.length : fn === 'min' ? Math.min(...nums) : Math.max(...nums);
193
+ }
194
+ return out;
195
+ };
196
+ if (!groupBy.length)
197
+ return measure(rows);
198
+ const buckets = new Map();
199
+ for (const r of rows) {
200
+ const key = JSON.stringify(groupBy.map((f) => { var _a; return (_a = field(r, f)) !== null && _a !== void 0 ? _a : null; }));
201
+ if (!buckets.has(key))
202
+ buckets.set(key, []);
203
+ buckets.get(key).push(r);
204
+ }
205
+ return { groups: [...buckets].map(([key, set]) => (Object.assign(Object.assign({}, Object.fromEntries(groupBy.map((f, i) => [f, JSON.parse(key)[i]]))), measure(set)))) };
206
+ },
150
207
  // Simple equality filter on top-level fields (recordType, proofId, customId, status, …).
151
208
  query: async (params = {}) => {
152
209
  const _a = params || {}, { limit, offset, sort } = _a, where = __rest(_a, ["limit", "offset", "sort"]);
@@ -158,6 +215,60 @@ export function createFunctionTestContext(opts) {
158
215
  return { data, pagination: { total: all.length, limit: size, offset: from, hasMore: from + data.length < all.length } };
159
216
  },
160
217
  };
218
+ // The app's history, in memory (newest first when read, like the platform).
219
+ let nextEventId = 1;
220
+ const memLog = (type, data = {}, opts = {}) => {
221
+ var _a;
222
+ if (typeof type !== 'string' || !/^[a-z][a-z0-9._-]{0,63}$/.test(type))
223
+ throw Object.assign(new Error('history type: lowercase letters, digits and . _ - (e.g. "stock.moved")'), { code: 'INVALID_INPUT', statusCode: 400 });
224
+ const ev = { id: `ev-${nextEventId++}`, at: new Date().toISOString(), type, entityId: (_a = opts.entityId) !== null && _a !== void 0 ? _a : null, data: data || {}, actorType: null, actorId: null };
225
+ mem.history.push(ev);
226
+ return { id: ev.id };
227
+ };
228
+ const memHistoryQuery = async (params = {}) => {
229
+ const types = params.types == null ? null : (Array.isArray(params.types) ? params.types : [params.types]);
230
+ return mem.history
231
+ .filter((e) => (!types || types.includes(e.type)) && (params.entityId == null || e.entityId === params.entityId) &&
232
+ Object.entries(params.data || {}).every(([k, v]) => (Array.isArray(v) ? v.map(String) : [String(v)]).includes(String(e.data[k]))))
233
+ .slice().reverse().slice(0, params.limit || 100).map((e) => (Object.assign({}, e)));
234
+ };
235
+ // All ops or none, like the platform: records are restored when an op fails; a repeat of an
236
+ // idempotencyKey replays the first results.
237
+ const memTransaction = async (ops, opts = {}) => {
238
+ const key = opts.idempotencyKey;
239
+ if (key && mem.transactions[key])
240
+ return { results: mem.transactions[key], replayed: true };
241
+ const before = JSON.parse(JSON.stringify(mem.records));
242
+ const historyBefore = mem.history.length;
243
+ const results = [];
244
+ for (let i = 0; i < ops.length; i++) {
245
+ const o = ops[i] || {};
246
+ try {
247
+ if (o.op === 'create')
248
+ results.push(await memRecords.create(o.record || {}));
249
+ else if (o.op === 'update')
250
+ results.push(await memRecords.update(o.recordId, o.fields || {}));
251
+ else if (o.op === 'adjust')
252
+ results.push(await memRecords.adjust(o.recordId, o.by, { atLeast: o.atLeast, atMost: o.atMost }));
253
+ else if (o.op === 'delete') {
254
+ await memRecords.delete(o.recordId);
255
+ results.push({ deleted: o.recordId });
256
+ }
257
+ else if (o.op === 'log')
258
+ results.push({ logged: memLog(o.type, o.data, { entityId: o.entityId }).id });
259
+ else
260
+ throw Object.assign(new Error(`ops[${i}].op must be create, update, adjust, delete or log`), { code: 'INVALID_INPUT', statusCode: 400 });
261
+ }
262
+ catch (err) {
263
+ mem.records.splice(0, mem.records.length, ...before);
264
+ mem.history.splice(historyBefore);
265
+ throw Object.assign(err, { opIndex: i });
266
+ }
267
+ }
268
+ if (key)
269
+ mem.transactions[key] = results;
270
+ return { results, replayed: false };
271
+ };
161
272
  const recordsFn = (name) => ar[name] ? ar[name].bind(ar) : (impl.appRecords ? undefined : memRecords[name]);
162
273
  const sl = {
163
274
  appRecords: {
@@ -169,6 +280,17 @@ export function createFunctionTestContext(opts) {
169
280
  get: gated('records', 'read', recordsFn('get'), 'get'),
170
281
  query: gated('records', 'read', recordsFn('query'), 'query'),
171
282
  listTypes: gated('records', 'read', recordsFn('listTypes'), 'listTypes'),
283
+ adjust: gated('records', 'write', recordsFn('adjust'), 'adjust'),
284
+ aggregate: gated('records', 'read', recordsFn('aggregate'), 'aggregate'),
285
+ },
286
+ transaction: gated('records', 'write', impl.transaction ? impl.transaction : (impl.appRecords ? undefined : async (ops, opts) => {
287
+ if (Array.isArray(ops) && ops.some((o) => o && o.op === 'log') && !allowsSl(parsed, 'history', 'write'))
288
+ throw new CapabilityError('sl:history:write');
289
+ return memTransaction(ops, opts);
290
+ }), 'transaction'),
291
+ history: {
292
+ log: gated('history', 'write', async (type, data, opts) => memLog(type, data, opts), 'log'),
293
+ query: gated('history', 'read', memHistoryQuery, 'query'),
172
294
  },
173
295
  products: {
174
296
  get: gated('products', 'read', pr.get && pr.get.bind(pr), 'get'),
@@ -490,6 +490,53 @@ export interface ServerFunctionBookings {
490
490
  }>;
491
491
  };
492
492
  }
493
+ /** One write in ctx.sl.transaction. */
494
+ export type RecordTransactionOp = {
495
+ op: 'create';
496
+ record: Record<string, any>;
497
+ } | {
498
+ op: 'update';
499
+ recordId: string;
500
+ fields: Record<string, any>;
501
+ } | {
502
+ op: 'adjust';
503
+ recordId: string;
504
+ by: Record<string, number>;
505
+ atLeast?: Record<string, number>;
506
+ atMost?: Record<string, number>;
507
+ } | {
508
+ op: 'delete';
509
+ recordId: string;
510
+ }
511
+ /** An event in the app's history (BigQuery): commits with the other ops, delivered after. Needs sl:history:write. */
512
+ | {
513
+ op: 'log';
514
+ type: string;
515
+ data?: Record<string, any>;
516
+ entityId?: string;
517
+ };
518
+ /** One event from an app's history. */
519
+ export interface AppHistoryEvent {
520
+ id: string;
521
+ at: string;
522
+ type: string;
523
+ entityId: string | null;
524
+ data: Record<string, any>;
525
+ actorType: 'user' | 'contact' | null;
526
+ actorId: string | null;
527
+ }
528
+ /** Reading an app's history: its own events in this collection, newest first. */
529
+ export interface AppHistoryQuery {
530
+ types?: string | string[];
531
+ entityId?: string;
532
+ /** Exact match on top-level data keys (one value or several). */
533
+ data?: Record<string, string | number | boolean | Array<string | number | boolean>>;
534
+ /** ISO dates. Default: the last 30 days; at most 400 days. */
535
+ from?: string;
536
+ to?: string;
537
+ /** Default 100, at most 1000. */
538
+ limit?: number;
539
+ }
493
540
  export interface ServerFunctionSl {
494
541
  /** THIS app's bookings, slots and blocks (`sl:bookings:read` / `sl:bookings:write`). */
495
542
  bookings: ServerFunctionBookings;
@@ -502,8 +549,48 @@ export interface ServerFunctionSl {
502
549
  */
503
550
  appRecords: {
504
551
  claim(fields: Record<string, any>): Promise<AppRecordClaimResult>;
552
+ /**
553
+ * Numbers in a record's `data`, changed in place in one statement: two scanners on one bin never lose
554
+ * a count. `by` `{ qty: -5 }` (missing counts as 0). `atLeast` / `atMost` are checked on the values
555
+ * AFTER the change: `{ atLeast: { qty: 0 } }` never takes a bin below zero. Refusals carry `code`:
556
+ * `NOT_FOUND`, `NOT_A_NUMBER`, `CONDITION_FAILED` (with `current` values). → the updated record.
557
+ */
558
+ adjust(recordId: string, by: Record<string, number>, opts?: {
559
+ atLeast?: Record<string, number>;
560
+ atMost?: Record<string, number>;
561
+ }): Promise<any>;
562
+ /** Totals over THIS app's records: count, sum/avg/min/max of data numbers, grouped by data keys. */
563
+ aggregate(params?: import('./appObjects.js').AggregateRequest): Promise<import('./appObjects.js').AggregateResponse & Record<string, unknown>>;
505
564
  [method: string]: any;
506
565
  };
566
+ /**
567
+ * Several record writes that land together or not at all (take 5 from bin A AND add 5 to order line B
568
+ * AND log the move). Needs `sl:records:write`; THIS app's records in THIS collection only. A failing op
569
+ * rolls everything back; its error says which (`opIndex`) and why (`code`). With `idempotencyKey`, a
570
+ * repeat of the key (a scanner retrying after a dropped signal) returns the first results
571
+ * (`replayed: true`) instead of writing twice; keys are remembered for 30 days.
572
+ */
573
+ transaction(ops: RecordTransactionOp[], opts?: {
574
+ idempotencyKey?: string;
575
+ }): Promise<{
576
+ results: any[];
577
+ replayed: boolean;
578
+ }>;
579
+ /**
580
+ * The app's HISTORY: append-only events kept in BigQuery (stock moved, a count taken, an order shipped),
581
+ * so the app's records hold current state and never grow with activity. `log` (`sl:history:write`)
582
+ * stores one event (it arrives within seconds; a failed delivery is retried, never lost); inside a
583
+ * transaction use a `log` op instead, so it commits with the change. `query` (`sl:history:read`) reads
584
+ * this app's events in this collection, newest first.
585
+ */
586
+ history: {
587
+ log(type: string, data?: Record<string, any>, opts?: {
588
+ entityId?: string;
589
+ }): Promise<{
590
+ id: string;
591
+ }>;
592
+ query(params?: AppHistoryQuery): Promise<AppHistoryEvent[]>;
593
+ };
507
594
  /** Atomic named counters for this app in this collection (`sl:data:write` / `sl:data:read`). */
508
595
  counters: ServerFunctionCounters;
509
596
  /** Tags (`sl:tags:read`): `resolve(id)` → the real tag in THIS collection, or `null` for a made-up id. */
@@ -55,8 +55,29 @@ export interface AggregateRequest {
55
55
  expires_at?: {
56
56
  lte?: string;
57
57
  };
58
+ updated_at?: {
59
+ gte?: string;
60
+ lte?: string;
61
+ };
62
+ /** Records only: exact match on top-level data keys, one value or any of several. */
63
+ data?: Record<string, string | number | boolean | Array<string | number | boolean>>;
64
+ /** Records only: numeric comparisons on top-level data keys (numbers only; others never match). */
65
+ dataRange?: Record<string, {
66
+ gte?: number;
67
+ gt?: number;
68
+ lte?: number;
69
+ lt?: number;
70
+ }>;
58
71
  };
72
+ /**
73
+ * Columns per resource; records also take top-level data keys as `'data.<key>'` (e.g. `'data.location'`),
74
+ * up to 5. Each group comes back with its key under the same name.
75
+ */
59
76
  groupBy?: string[];
77
+ /**
78
+ * `'count'`; records also take `'sum:<key>'`, `'avg:<key>'`, `'min:<key>'`, `'max:<key>'` over numbers in a
79
+ * top-level data key (non-numbers ignored) → `sum_<key>`, `avg_<key>`… in the result.
80
+ */
60
81
  metrics?: string[];
61
82
  timeSeriesField?: string;
62
83
  timeSeriesInterval?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
@@ -66,7 +87,7 @@ export interface AggregateRequest {
66
87
  */
67
88
  export interface AggregateResponse {
68
89
  groups?: ({
69
- count: number;
90
+ count?: number;
70
91
  } & Record<string, unknown>)[];
71
92
  timeSeries?: ({
72
93
  bucket: string;
@@ -361,6 +361,8 @@ export interface PartnerTagHit {
361
361
  url: string | null;
362
362
  };
363
363
  range: ClaimSetRange | null;
364
+ /** The group the tag was added to, if any */
365
+ group: ClaimSetRange | null;
364
366
  }
365
367
  export interface CreatePartnerClaimSetRequest {
366
368
  name: string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * An app reporting an error it saw (errors.report): kept in the platform's one error log (CloudEvents + ECS
3
+ * rows in BigQuery), the same place server-side errors go. Say what it was about with entityType/entityId
4
+ * (a tag's chip UID, a product id, an order id), put routing facts in labels and the detail in context.
5
+ * Never include keys, passwords or tokens: the server drops any field whose name looks like one.
6
+ */
7
+ export interface ClientErrorReport {
8
+ /** Which app: 'sidekick', 'studio', a site's app id. Default 'client'. */
9
+ app?: string;
10
+ appVersion?: string;
11
+ /** The collection it concerns, when there is one (owners see their collection's errors) */
12
+ collectionId?: string;
13
+ /** What was being done: 'tag.program', 'order.submit', … */
14
+ operation?: string;
15
+ severity?: 'debug' | 'info' | 'warning' | 'error' | 'critical';
16
+ /** A short stable code: 'TAG_LOST', 'TIMEOUT', … (this or message is required) */
17
+ errorCode?: string;
18
+ errorType?: string;
19
+ message?: string;
20
+ stack?: string;
21
+ /** What it was about: 'tag', 'product', 'order', … */
22
+ entityType?: string;
23
+ /** …and which one: a chip UID (normalised for 'tag'), a product id, … */
24
+ entityId?: string;
25
+ labels?: Record<string, unknown>;
26
+ context?: Record<string, unknown>;
27
+ /** An operation id to group by (a batch, a job) */
28
+ refId?: string;
29
+ correlationId?: string;
30
+ }
31
+ /** One kept error event, as the lookups return it (newest first). */
32
+ export interface ClientErrorEvent {
33
+ id: string;
34
+ time: string;
35
+ event_category: string;
36
+ event_action: string | null;
37
+ log_level: string;
38
+ error_code: string | null;
39
+ error_message: string | null;
40
+ service_name: string | null;
41
+ service_version: string | null;
42
+ collection_id: string | null;
43
+ user_id: string | null;
44
+ labels: Record<string, unknown> | null;
45
+ context: Record<string, unknown> | null;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -45,3 +45,4 @@ export * from "./research.js";
45
45
  export * from "./config.js";
46
46
  export * from "./widgets.js";
47
47
  export * from "./headless.js";
48
+ export * from "./clientLog.js";
@@ -47,3 +47,4 @@ export * from "./research.js";
47
47
  export * from "./config.js";
48
48
  export * from "./widgets.js";
49
49
  export * from "./headless.js";
50
+ export * from "./clientLog.js";
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.16 | Generated: 2026-11-10T17:19:48.505Z
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
@@ -5966,6 +6007,7 @@ interface PartnerTagHit {
5966
6007
  set: PartnerClaimSet | null
5967
6008
  tag: ClaimSetRangeTag & { url: string | null }
5968
6009
  range: ClaimSetRange | null
6010
+ group: ClaimSetRange | null
5969
6011
  }
5970
6012
  ```
5971
6013
 
@@ -5993,6 +6035,48 @@ type ClaimSetScope = { collectionId: string }
5993
6035
 
5994
6036
  **ClaimSetImportResult** = ``
5995
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
+
5996
6080
  ### collection
5997
6081
 
5998
6082
  **Collection** (interface)
@@ -12830,6 +12914,10 @@ Update a crate for a collection (admin only). ```typescript const updated = awai
12830
12914
  crateId: string) → `Promise<DeleteCrateResponse>`
12831
12915
  Delete a crate for a collection (admin only). This performs a soft delete. ```typescript await crate.remove('coll_123', 'crate_abc123') ```
12832
12916
 
12917
+ ### errors
12918
+
12919
+ **report**(event: ClientErrorReport) → `Promise<`
12920
+
12833
12921
  ### facets
12834
12922
 
12835
12923
  **list**(collectionId: string,
@@ -13462,6 +13550,9 @@ A new claim set for the partner, on one of its tap domains (owners and admins).
13462
13550
  **lookupTags**(partnerId: string, query: { uid?: string; code?: string }) → `Promise<PartnerTagHit[]>`
13463
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.
13464
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
+
13465
13556
  ### podcast
13466
13557
 
13467
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: