@lowdefy/helpers 6.1.0 → 7.0.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.
@@ -0,0 +1,25 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import stampHash from './stampHash.js';
16
+ // The stamp hash of a list whose order carries no meaning (the organization ids
17
+ // a person belongs to): sorted first, so the same set always hashes the same,
18
+ // and JSON-encoded, so no separator inside an entry can make two lists read as
19
+ // one.
20
+ function listHash({ values }) {
21
+ return stampHash(JSON.stringify([
22
+ ...values
23
+ ].sort()));
24
+ }
25
+ export default listHash;
@@ -0,0 +1,30 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { snakeCase } from 'change-case';
16
+ import type from './type.js';
17
+ // Shallow snake_cases every own top-level key of the resolved caller - the
18
+ // object mirror of the adapter's schema-wide fieldName derive. Values are
19
+ // copied by reference: the attributes and profile bags (and the roles array)
20
+ // ride through whole, so their inner keys - app/module-owned, not platform
21
+ // surface - are never transformed.
22
+ function normalizeCaller(user) {
23
+ if (type.isNone(user)) return user;
24
+ const normalized = {};
25
+ for (const [key, value] of Object.entries(user)){
26
+ normalized[snakeCase(key)] = value;
27
+ }
28
+ return normalized;
29
+ }
30
+ export default normalizeCaller;
@@ -0,0 +1,36 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import buildPagePath from './buildPagePath.js';
16
+ import parsePathPattern from './parsePathPattern.js';
17
+ import type from './type.js';
18
+ // The key a page instance's context and input are stored under. A page with placeholders has one
19
+ // instance per set of decoded values, keyed by the path the builder writes for them, so every
20
+ // spelling of the same values in an arriving URL gives one key. "#" cannot appear in a page id and
21
+ // the builder encodes it in values, so no instance key equals another page's key.
22
+ function pageInstanceKey({ pageId, path, pathParams }) {
23
+ if (type.isUndefined(path)) {
24
+ return `page:${pageId}`;
25
+ }
26
+ const hasPlaceholders = parsePathPattern(path).some((segment)=>type.isString(segment.name));
27
+ if (!hasPlaceholders) {
28
+ return `page:${pageId}`;
29
+ }
30
+ return `page:${pageId}#${buildPagePath({
31
+ pageId,
32
+ path,
33
+ pathParams
34
+ })}`;
35
+ }
36
+ export default pageInstanceKey;
@@ -0,0 +1,102 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import isMountEventName from './isMountEventName.js';
16
+ import type from './type.js';
17
+ // Absorbs event-loop and render delay between the DOM interaction and the
18
+ // moment the engine starts the event's actions, without reaching back to an
19
+ // unrelated earlier click.
20
+ const PAIRING_SLACK_MS = 1000;
21
+ function canBeCaused({ event }) {
22
+ return event.scope !== 'app' && !isMountEventName({
23
+ eventName: event.eventName
24
+ });
25
+ }
26
+ // An interaction can have caused an event when the event's block encloses the
27
+ // interaction's target (the handler is on the clicked block or an ancestor of
28
+ // it) and the event started no later than its debounce plus the slack after
29
+ // the interaction.
30
+ function couldCause({ event, interaction }) {
31
+ if (!interaction.blockIds.includes(event.blockId)) return false;
32
+ const delay = event.startTimestamp - interaction.t;
33
+ const windowMs = (event.debounceMs ?? 0) + PAIRING_SLACK_MS;
34
+ return delay >= 0 && delay <= windowMs;
35
+ }
36
+ function findCause({ event, interactions }) {
37
+ let cause;
38
+ interactions.forEach((interaction)=>{
39
+ if (!couldCause({
40
+ event,
41
+ interaction
42
+ })) return;
43
+ if (type.isUndefined(cause) || interaction.t >= cause.t) {
44
+ cause = interaction;
45
+ }
46
+ });
47
+ return cause;
48
+ }
49
+ // Decides which DOM interaction caused which engine event, so a trace record
50
+ // means the same thing whichever source wrote it: the dev recorder runs this in
51
+ // the browser, the production pull in Node.
52
+ //
53
+ // - interactions: [{ id, t, blockIds }], `t` epoch ms, `blockIds` innermost first.
54
+ // - events: [{ id, blockId, eventName, startTimestamp, debounceMs, scope }].
55
+ //
56
+ // Mount-class and app events are never paired. Any other event pairs with the
57
+ // latest interaction that could have caused it; an event with none is
58
+ // programmatic and goes to `unpaired`. Within a pair, events on the innermost
59
+ // block come first, then the earliest, so the record builder takes the first
60
+ // as `event` and the rest as `also`.
61
+ function pairTraceEvents({ interactions, events }) {
62
+ const byInteraction = new Map();
63
+ const unpaired = [];
64
+ events.forEach((event, order)=>{
65
+ const cause = canBeCaused({
66
+ event
67
+ }) ? findCause({
68
+ event,
69
+ interactions
70
+ }) : undefined;
71
+ if (type.isUndefined(cause)) {
72
+ unpaired.push(event.id);
73
+ return;
74
+ }
75
+ if (!byInteraction.has(cause)) byInteraction.set(cause, []);
76
+ byInteraction.get(cause).push({
77
+ depth: cause.blockIds.indexOf(event.blockId),
78
+ event,
79
+ order
80
+ });
81
+ });
82
+ const pairs = interactions.filter((interaction)=>byInteraction.has(interaction)).map((interaction)=>{
83
+ const paired = [
84
+ ...byInteraction.get(interaction)
85
+ ].sort((a, b)=>{
86
+ if (a.depth !== b.depth) return a.depth - b.depth;
87
+ if (a.event.startTimestamp !== b.event.startTimestamp) {
88
+ return a.event.startTimestamp - b.event.startTimestamp;
89
+ }
90
+ return a.order - b.order;
91
+ });
92
+ return {
93
+ interactionId: interaction.id,
94
+ eventIds: paired.map((entry)=>entry.event.id)
95
+ };
96
+ });
97
+ return {
98
+ pairs,
99
+ unpaired
100
+ };
101
+ }
102
+ export default pairTraceEvents;
@@ -0,0 +1,46 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ const FIELDS = [
17
+ 'session',
18
+ 'roles',
19
+ 'memberships'
20
+ ];
21
+ // Reads an x-lowdefy-caller header (see callerStamp) into
22
+ // { caller, session, roles, memberships }. A field the header does not carry is
23
+ // null; an anonymous caller is { caller: '' } with every field null. No header
24
+ // is null: the response was not answered by a route that resolved a caller (it
25
+ // failed before, or came from a proxy), so it claims no caller.
26
+ function parseCallerStamp(header) {
27
+ if (type.isNone(header)) {
28
+ return null;
29
+ }
30
+ const [caller, ...fields] = header.split(';').map((part)=>part.trim());
31
+ const stamp = {
32
+ caller,
33
+ session: null,
34
+ roles: null,
35
+ memberships: null
36
+ };
37
+ fields.forEach((field)=>{
38
+ const separator = field.indexOf('=');
39
+ const name = field.slice(0, separator);
40
+ if (separator > 0 && FIELDS.includes(name)) {
41
+ stamp[name] = field.slice(separator + 1);
42
+ }
43
+ });
44
+ return stamp;
45
+ }
46
+ export default parseCallerStamp;
@@ -0,0 +1,58 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import isChainHeaderEnd from './isChainHeaderEnd.js';
16
+ import readChainValue from './readChainValue.js';
17
+ function parseHeader(header) {
18
+ const [tag, ...classes] = header.split('.');
19
+ return {
20
+ tag: tag.toLowerCase(),
21
+ classes: classes.filter((name)=>name !== '')
22
+ };
23
+ }
24
+ // Splits a posthog-js `$elements_chain` into entries, target first. Each entry is
25
+ // `tag.class1.class2:` followed by sorted `key="value"` pairs, and entries are joined by `;`.
26
+ // Total: a malformed chain gives the entries read before the fault.
27
+ function parseElementsChain(chain) {
28
+ const entries = [];
29
+ let index = 0;
30
+ while(index < chain.length){
31
+ let headerEnd = index;
32
+ while(headerEnd < chain.length && chain[headerEnd] !== ';' && !isChainHeaderEnd(chain, headerEnd)){
33
+ headerEnd += 1;
34
+ }
35
+ const entry = {
36
+ ...parseHeader(chain.slice(index, headerEnd)),
37
+ attributes: {}
38
+ };
39
+ entries.push(entry);
40
+ if (chain[headerEnd] !== ':') {
41
+ index = headerEnd + 1;
42
+ continue;
43
+ }
44
+ index = headerEnd + 1;
45
+ while(index < chain.length && chain[index] !== ';'){
46
+ const keyEnd = chain.indexOf('="', index);
47
+ if (keyEnd === -1) {
48
+ return entries;
49
+ }
50
+ const { value, end } = readChainValue(chain, keyEnd + 2);
51
+ entry.attributes[chain.slice(index, keyEnd)] = value;
52
+ index = end;
53
+ }
54
+ index += 1;
55
+ }
56
+ return entries;
57
+ }
58
+ export default parseElementsChain;
@@ -0,0 +1,23 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The page path a URL shows: its pathname without the basePath and the outer slashes. It is the
16
+ // page id only for pages without a `path` pattern. The app root gives null.
17
+ function parsePageId(url, basePath = '') {
18
+ const pathname = new URL(url, 'http://localhost').pathname;
19
+ const stripped = basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
20
+ const pageId = stripped.replace(/^\//, '').replace(/\/$/, '');
21
+ return pageId === '' ? null : pageId;
22
+ }
23
+ export default parsePageId;
@@ -0,0 +1,88 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ // The page id character set without "/", which separates segments.
17
+ const fixedSegmentPattern = /^[A-Za-z0-9\-_:]+$/;
18
+ const placeholderNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ function parsePlaceholder({ path, segment }) {
20
+ const name = segment.slice(1, -1);
21
+ if (name.startsWith('...')) {
22
+ throw new Error(`Page path "${path}" has a catch-all placeholder "${segment}". Catch-all placeholders are not supported.`);
23
+ }
24
+ if (name.startsWith('?') || name.endsWith('?')) {
25
+ throw new Error(`Page path "${path}" has an optional placeholder "${segment}". Optional placeholders are not supported.`);
26
+ }
27
+ if (!placeholderNamePattern.test(name)) {
28
+ throw new Error(`Page path "${path}" has an invalid placeholder "${segment}". Placeholder names must start with a letter or "_" and only contain A-Z, a-z, 0-9 and "_".`);
29
+ }
30
+ return {
31
+ name
32
+ };
33
+ }
34
+ function parseSegment({ path, segment }) {
35
+ if (segment === '') {
36
+ throw new Error(`Page path "${path}" has an empty segment.`);
37
+ }
38
+ if (segment.startsWith('{') && segment.endsWith('}')) {
39
+ return parsePlaceholder({
40
+ path,
41
+ segment
42
+ });
43
+ }
44
+ if (segment.includes('{') || segment.includes('}')) {
45
+ throw new Error(`Page path "${path}" has a placeholder inside segment "${segment}". A placeholder must be a whole segment, like "{id}".`);
46
+ }
47
+ if (!fixedSegmentPattern.test(segment)) {
48
+ throw new Error(`Page path "${path}" segment "${segment}" contains invalid characters. Fixed segments must only contain A-Z, a-z, 0-9, "-", "_" and ":".`);
49
+ }
50
+ return {
51
+ fixed: segment
52
+ };
53
+ }
54
+ function parseSegments(path) {
55
+ if (path.startsWith('/') || path.endsWith('/')) {
56
+ throw new Error(`Page path "${path}" must not start or end with "/".`);
57
+ }
58
+ const names = new Set();
59
+ return path.split('/').map((segment)=>{
60
+ const parsed = parseSegment({
61
+ path,
62
+ segment
63
+ });
64
+ if (type.isString(parsed.name)) {
65
+ if (names.has(parsed.name)) {
66
+ throw new Error(`Page path "${path}" uses placeholder "${segment}" more than once.`);
67
+ }
68
+ names.add(parsed.name);
69
+ }
70
+ return Object.freeze(parsed);
71
+ });
72
+ }
73
+ // Patterns come from build config, so the cache holds at most one entry per page path.
74
+ const cache = new Map();
75
+ // Splits a page path pattern into segments, each { fixed } or { name }. The result is shared
76
+ // between callers and frozen.
77
+ function parsePathPattern(path) {
78
+ if (!type.isString(path)) {
79
+ throw new Error(`Page path must be a string. Received ${JSON.stringify(path)}.`);
80
+ }
81
+ if (cache.has(path)) {
82
+ return cache.get(path);
83
+ }
84
+ const segments = Object.freeze(parseSegments(path));
85
+ cache.set(path, segments);
86
+ return segments;
87
+ }
88
+ export default parsePathPattern;
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ // An ag-grid `row-index` as a journey target row. Pinned rows (`t-0`, `b-0`) are not zero-based
17
+ // integers, so they give null.
18
+ function parseRowIndex(value) {
19
+ if (!type.isString(value) || !/^\d+$/.test(value)) {
20
+ return null;
21
+ }
22
+ return Number(value);
23
+ }
24
+ export default parseRowIndex;
@@ -50,7 +50,10 @@ function projectNode(error, scrub, seen) {
50
50
  // The server's error sink sets `handled` once it has logged the error, and
51
51
  // the client skips reporting a handled error back to the server. A rethrown
52
52
  // `_error` without it would be logged a second time.
53
- handled: error.handled
53
+ handled: error.handled,
54
+ // A ServiceError's Retry-After (seconds, or an HTTP date), so a routine can wait as long
55
+ // as the service asked before retrying, such as an enrichment worker's backoff.
56
+ retryAfter: error.name === 'ServiceError' ? error.retryAfter ?? undefined : undefined
54
57
  };
55
58
  for (const [key, value] of Object.entries(fields)){
56
59
  if (!type.isUndefined(value)) projected[key] = value;
@@ -69,7 +72,8 @@ function projectNode(error, scrub, seen) {
69
72
  return projected;
70
73
  }
71
74
  // The value `_error` reads: the caught error rebuilt with only the fields a
72
- // routine or catch list branches on. `received`, `configKey`, `location`,
75
+ // routine or catch list branches on (name, message, code, statusCode, handled, a
76
+ // ServiceError's retryAfter, and the cause). `received`, `configKey`, `location`,
73
77
  // `source`, `config` and `stack` are left out - `received` can hold a token
74
78
  // fetched at runtime that the scrub does not know. The result is an Error, so
75
79
  // wherever config sends it on, the serializer's wire policy applies to it.
@@ -0,0 +1,42 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // Reads a quoted value from `start` (just past its opening quote). posthog-js escapes a quote
16
+ // in a value as \". Returns the value and the index past its closing quote, or the end of the
17
+ // chain when the quote is never closed.
18
+ function readChainValue(chain, start) {
19
+ let value = '';
20
+ let index = start;
21
+ while(index < chain.length){
22
+ const char = chain[index];
23
+ if (char === '\\' && chain[index + 1] === '"') {
24
+ value += '"';
25
+ index += 2;
26
+ continue;
27
+ }
28
+ if (char === '"') {
29
+ return {
30
+ value,
31
+ end: index + 1
32
+ };
33
+ }
34
+ value += char;
35
+ index += 1;
36
+ }
37
+ return {
38
+ value,
39
+ end: index
40
+ };
41
+ }
42
+ export default readChainValue;
@@ -0,0 +1,34 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import stampHash from './stampHash.js';
16
+ // The stamp hash of a caller's roles in their active organization: the app
17
+ // roles (_user.roles, which page and endpoint auth gates match) and the
18
+ // organization role tier (_user.org_roles: owner, admin or member), since app
19
+ // config can gate blocks on either. Each list is sorted, so the same set always
20
+ // hashes the same, and the pair is JSON-encoded, so no separator inside a role
21
+ // can make two lists read as one. The server stamps it on every response
22
+ // (callerStamp) and the client compares it with the roles of the caller it
23
+ // renders, so both sides use this one function.
24
+ function rolesHash({ user }) {
25
+ return stampHash(JSON.stringify({
26
+ roles: [
27
+ ...user.roles ?? []
28
+ ].sort(),
29
+ org_roles: [
30
+ ...user.org_roles ?? []
31
+ ].sort()
32
+ }));
33
+ }
34
+ export default rolesHash;
@@ -206,11 +206,12 @@ const serialize = (json, options = {})=>{
206
206
  '~d': json.valueOf()
207
207
  };
208
208
  }
209
- // skipMarkers is deliberately not threaded here - serialize has never applied
210
- // it, and its callers depend on markers surviving.
209
+ // Markers survive unless the caller passes skipMarkers: config sent to the
210
+ // client needs them to resolve error locations, data does not.
211
211
  return JSON.parse(JSON.stringify(json, makeReplacer({
212
212
  replacer: options.replacer,
213
213
  isoStringDates: options.isoStringDates,
214
+ skipMarkers: options.skipMarkers,
214
215
  projectError: options.projectError
215
216
  })));
216
217
  };