pryv 3.5.0 → 3.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
package/src/Connection.js CHANGED
@@ -8,6 +8,7 @@ const libGetEventStreamed = require('./lib/getEventStreamed');
8
8
  const PryvError = require('./lib/PryvError');
9
9
  const StaleAccessIdError = require('./lib/StaleAccessIdError');
10
10
  const buildSearchParams = require('./lib/buildSearchParams');
11
+ const resolveDotPath = require('./lib/resolveDotPath');
11
12
 
12
13
  /**
13
14
  * @class Connection
@@ -350,6 +351,44 @@ class Connection {
350
351
  return res.body;
351
352
  }
352
353
 
354
+ /**
355
+ * Get the latest event per value for a content path — typical form-prefill
356
+ * lookup ("latest assertion per code"). Queries `events.get` with a
357
+ * `content` condition `{ path, in: values }` (server must support content
358
+ * queries — see `Service.supportsContentQueries()`), pages through the
359
+ * time-descending result and keeps the first (= latest) event per value.
360
+ * Handles paging internally, so the result is correct regardless of the
361
+ * default `events.get` page size.
362
+ * @param {string} path - dot-path into `content` (or `$` for the root value)
363
+ * @param {Array<string|number|boolean>} values - values to look up (one Map entry max per value)
364
+ * @param {Object} [baseQuery] - additional `events.get` params (e.g. `streams`, `types`, `fromTime`); passed through
365
+ * @returns {Promise<Map<string|number|boolean, Object>>} value → latest matching event; values with no match are absent
366
+ */
367
+ async getLatestByContent (path, values, baseQuery = {}) {
368
+ const PAGE_LIMIT = 1000;
369
+ const lookup = new Set(values);
370
+ const found = new Map();
371
+ const condition = { path, in: [...lookup] };
372
+ const content = (baseQuery.content || []).concat([condition]);
373
+ let skip = 0;
374
+ while (found.size < lookup.size) {
375
+ const params = Object.assign({}, baseQuery, {
376
+ content,
377
+ sortAscending: false,
378
+ skip,
379
+ limit: PAGE_LIMIT
380
+ });
381
+ const events = await this.apiOne('events.get', params, 'events');
382
+ for (const event of events) {
383
+ const value = resolveDotPath(event.content, path);
384
+ if (lookup.has(value) && !found.has(value)) found.set(value, event);
385
+ }
386
+ if (events.length < PAGE_LIMIT) break;
387
+ skip += events.length;
388
+ }
389
+ return found;
390
+ }
391
+
353
392
  /**
354
393
  * Create an event with attached file
355
394
  * NODE.jS ONLY
package/src/Service.js CHANGED
@@ -80,6 +80,17 @@ class Service {
80
80
  return (infos.features == null || infos.features.noHF !== true);
81
81
  }
82
82
 
83
+ /**
84
+ * Whether the platform supports `events.get` content/clientData query
85
+ * conditions (`features.contentQueries` in service info). Older platforms
86
+ * reject the parameters with a 400 — use this to pick a fallback path.
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ async supportsContentQueries () {
90
+ const infos = await this.info();
91
+ return infos.features != null && infos.features.contentQueries === true;
92
+ }
93
+
83
94
  /**
84
95
  * Check if a service has username in the hostname or in the path of the API.
85
96
  * @returns {Promise<boolean>} Promise resolving to true if the service does not rely on DNS to find a host related to a username
@@ -622,4 +633,5 @@ const Connection = require('./Connection');
622
633
  * @property {string} [assets.definitions] URL to json object with assets definitions
623
634
  * @property {Object} [features] Platform feature flags
624
635
  * @property {boolean} [features.noHF] True if HF data is not supported
636
+ * @property {boolean} [features.contentQueries] True if events.get content/clientData query conditions are supported
625
637
  */
package/src/index.d.ts CHANGED
@@ -267,6 +267,22 @@ declare module 'pryv' {
267
267
  not?: Identifier[];
268
268
  };
269
269
 
270
+ /**
271
+ * Condition on a JSON path of events' `content` or `clientData`.
272
+ * `path` is a dot-path (segments: [a-zA-Z0-9_:-]) or `$` for the root
273
+ * value; exactly one operator per condition. Conditions AND together.
274
+ * Server support is advertised by `features.contentQueries` (see
275
+ * `Service.supportsContentQueries()`).
276
+ */
277
+ export type ContentQueryCondition = { path: string } & (
278
+ | { eq: string | number | boolean }
279
+ | { neq: string | number | boolean }
280
+ | { in: Array<string | number | boolean> }
281
+ | { exists: boolean }
282
+ | { gt: number } | { gte: number } | { lt: number } | { lte: number }
283
+ | { prefix: string }
284
+ );
285
+
270
286
  export type EventQueryParams = {
271
287
  fromTime: Timestamp;
272
288
  toTime: Timestamp;
@@ -280,6 +296,8 @@ declare module 'pryv' {
280
296
  state: 'default' | 'trashed' | 'all';
281
297
  modifiedSince: Timestamp;
282
298
  includeDeletion: boolean;
299
+ content: ContentQueryCondition[];
300
+ clientData: ContentQueryCondition[];
283
301
  };
284
302
 
285
303
  export type EventQueryParamsStreamQuery = Omit<EventQueryParams, 'streams'> & {
@@ -705,6 +723,15 @@ declare module 'pryv' {
705
723
  queryParams: Partial<EventQueryParamsStreamQuery>,
706
724
  forEachEvent: StreamedEventsHandler,
707
725
  ): Promise<StreamedEventsResult>;
726
+ /**
727
+ * Latest event per value for a content path (form-prefill lookup).
728
+ * Pages internally; requires server content-query support.
729
+ */
730
+ getLatestByContent(
731
+ path: string,
732
+ values: Array<string | number | boolean>,
733
+ baseQuery?: Partial<EventQueryParamsStreamQuery>,
734
+ ): Promise<Map<string | number | boolean, Event>>;
708
735
  createEventWithFile(
709
736
  params: EventFileCreationParams,
710
737
  filePath: string | Buffer | Blob,
@@ -826,6 +853,8 @@ declare module 'pryv' {
826
853
  ): Promise<Connection>;
827
854
 
828
855
  supportsHF(): Promise<boolean>;
856
+ /** Whether the platform supports events.get content/clientData query conditions. */
857
+ supportsContentQueries(): Promise<boolean>;
829
858
  isDnsLess(): Promise<boolean>;
830
859
 
831
860
  userExists(userId: string): Promise<boolean>;
@@ -4,8 +4,12 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Build URL search params string from an object, properly handling arrays.
8
- * Arrays are expanded as repeated keys: { a: ['x', 'y'] } => 'a=x&a=y'
7
+ * Build URL search params string from an object, properly handling arrays
8
+ * and structured values.
9
+ * - Arrays of scalars are expanded as repeated keys: { a: ['x', 'y'] } => 'a=x&a=y'
10
+ * - Arrays containing objects (e.g. `content` / `clientData` conditions,
11
+ * rich `streams` queries) and plain objects are sent as one
12
+ * JSON-encoded parameter, which the API parses back.
9
13
  * @param {Object} params - Query parameters object
10
14
  * @returns {string} - URL encoded query string
11
15
  */
@@ -13,9 +17,15 @@ function buildSearchParams (params) {
13
17
  const searchParams = new URLSearchParams();
14
18
  for (const [key, value] of Object.entries(params)) {
15
19
  if (Array.isArray(value)) {
16
- for (const item of value) {
17
- searchParams.append(key, item);
20
+ if (value.some((item) => item !== null && typeof item === 'object')) {
21
+ searchParams.append(key, JSON.stringify(value));
22
+ } else {
23
+ for (const item of value) {
24
+ searchParams.append(key, item);
25
+ }
18
26
  }
27
+ } else if (value !== null && typeof value === 'object') {
28
+ searchParams.append(key, JSON.stringify(value));
19
29
  } else if (value !== undefined && value !== null) {
20
30
  searchParams.append(key, value);
21
31
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * Resolve a content-query dot-path against a JSON value.
8
+ * `$` addresses the root value itself. Returns `undefined` when the path
9
+ * does not lead to a value.
10
+ * @param {*} root - The JSON value (typically an event's `content`)
11
+ * @param {string} path - Dot-path (e.g. 'drug.codes.atc') or '$'
12
+ * @returns {*} The value at the path, or undefined
13
+ */
14
+ function resolveDotPath (root, path) {
15
+ if (path === '$') return root;
16
+ let current = root;
17
+ for (const segment of path.split('.')) {
18
+ if (current == null || typeof current !== 'object' || Array.isArray(current)) return undefined;
19
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
20
+ current = current[segment];
21
+ }
22
+ return current;
23
+ }
24
+
25
+ module.exports = resolveDotPath;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, before, expect, pryv */
6
+
7
+ const { createId: cuid } = require('@paralleldrive/cuid2');
8
+ const testData = require('../../../test/test-data');
9
+
10
+ const buildSearchParams = require('../src/lib/buildSearchParams');
11
+ const resolveDotPath = require('../src/lib/resolveDotPath');
12
+
13
+ describe('[CQLJ] Content queries', () => {
14
+ describe('[CQLU] buildSearchParams structured values', () => {
15
+ it('[LU01] keeps scalar arrays as repeated keys', () => {
16
+ expect(buildSearchParams({ a: ['x', 'y'], b: 1 })).to.equal('a=x&a=y&b=1');
17
+ });
18
+
19
+ it('[LU02] JSON-encodes arrays of objects (conditions) as one parameter', () => {
20
+ const conditions = [{ path: 'drug.codes.atc', eq: 'G03DA04' }];
21
+ const out = buildSearchParams({ content: conditions });
22
+ expect(decodeURIComponent(out)).to.equal('content=' + JSON.stringify(conditions));
23
+ });
24
+
25
+ it('[LU03] JSON-encodes plain object values', () => {
26
+ const out = buildSearchParams({ streams: { any: ['a'] } });
27
+ expect(decodeURIComponent(out.replace(/\+/g, ' '))).to.equal('streams=' + JSON.stringify({ any: ['a'] }));
28
+ });
29
+ });
30
+
31
+ describe('[CQLR] resolveDotPath', () => {
32
+ it('[LR01] resolves nested paths, root $ and misses', () => {
33
+ const content = { drug: { codes: { atc: 'G03DA04' } }, taken: true };
34
+ expect(resolveDotPath(content, 'drug.codes.atc')).to.equal('G03DA04');
35
+ expect(resolveDotPath(content, 'taken')).to.equal(true);
36
+ expect(resolveDotPath(content, 'drug.codes.snomed')).to.equal(undefined);
37
+ expect(resolveDotPath(14.2, '$')).to.equal(14.2);
38
+ expect(resolveDotPath(undefined, 'a.b')).to.equal(undefined);
39
+ expect(resolveDotPath({ a: [1] }, 'a.0')).to.equal(undefined); // no array indices
40
+ });
41
+ });
42
+
43
+ describe('[CQLI] against a live platform (skipped when unsupported)', () => {
44
+ let conn, supported, streamId;
45
+ const codes = { progesterone: 'G03DA04', aspirin: 'B01AC06', missing: 'N02BE01' };
46
+
47
+ before(async function () {
48
+ this.timeout(15000);
49
+ await testData.prepare();
50
+ conn = new pryv.Connection(testData.apiEndpointWithToken);
51
+ supported = await conn.service.supportsContentQueries();
52
+ if (!supported) return;
53
+
54
+ streamId = 'cq-' + cuid().substring(0, 8);
55
+ const calls = [{ method: 'streams.create', params: { id: streamId, name: 'CQ ' + streamId, parentId: 'data' } }];
56
+ // two assertions per drug — older taken:false, newer taken:true — to verify "latest"
57
+ let time = 1700000000;
58
+ for (const code of [codes.progesterone, codes.aspirin]) {
59
+ calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: false } } });
60
+ calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: true } } });
61
+ }
62
+ const res = await conn.api(calls);
63
+ for (const r of res) expect(r.error).to.be.undefined;
64
+ });
65
+
66
+ it('[LI01] supportsContentQueries returns a boolean', async () => {
67
+ expect(typeof supported).to.equal('boolean');
68
+ });
69
+
70
+ it('[LI02] events.get accepts content conditions via GET (streamed)', async function () {
71
+ if (!supported) this.skip();
72
+ const collected = [];
73
+ await conn.getEventsStreamed(
74
+ { streams: [streamId], content: [{ path: 'drug.codes.atc', eq: codes.progesterone }, { path: 'taken', eq: true }] },
75
+ (event) => collected.push(event)
76
+ );
77
+ expect(collected.length).to.equal(1);
78
+ expect(collected[0].content.taken).to.equal(true);
79
+ });
80
+
81
+ it('[LI03] getLatestByContent returns the latest event per value', async function () {
82
+ if (!supported) this.skip();
83
+ const byCode = await conn.getLatestByContent(
84
+ 'drug.codes.atc',
85
+ [codes.progesterone, codes.aspirin, codes.missing],
86
+ { streams: [streamId] }
87
+ );
88
+ expect(byCode.size).to.equal(2);
89
+ expect(byCode.get(codes.missing)).to.be.undefined;
90
+ // latest per code is the taken:true one (created later)
91
+ expect(byCode.get(codes.progesterone).content.taken).to.equal(true);
92
+ expect(byCode.get(codes.aspirin).content.taken).to.equal(true);
93
+ });
94
+ });
95
+ });