@speedkit/cli 4.26.0 → 4.27.1

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.
Files changed (149) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +203 -15
  3. package/dist/commands/auto-prewarm.d.ts +12 -1
  4. package/dist/commands/auto-prewarm.js +26 -7
  5. package/dist/commands/generate-pop-config.d.ts +1 -0
  6. package/dist/commands/generate-pop-config.js +9 -3
  7. package/dist/commands/prewarm.js +3 -1
  8. package/dist/commands/query/parameter.d.ts +27 -0
  9. package/dist/commands/query/parameter.js +28 -0
  10. package/dist/commands/query/prewarm.d.ts +32 -0
  11. package/dist/commands/query/prewarm.js +32 -0
  12. package/dist/helpers/clipboard.d.ts +19 -0
  13. package/dist/helpers/clipboard.js +70 -0
  14. package/dist/helpers/evaluate-speed-kit-config.d.ts +8 -18
  15. package/dist/helpers/evaluate-speed-kit-config.js +8 -6
  16. package/dist/helpers/evaluate-speed-kit-config.spec.d.ts +1 -0
  17. package/dist/helpers/evaluate-speed-kit-config.spec.js +78 -0
  18. package/dist/models/cli-parameters.d.ts +106 -0
  19. package/dist/models/cli-parameters.js +183 -0
  20. package/dist/models/cli-parameters.spec.d.ts +1 -0
  21. package/dist/models/cli-parameters.spec.js +47 -0
  22. package/dist/services/athena/athena-service-factory.d.ts +4 -1
  23. package/dist/services/athena/athena-service-factory.js +9 -3
  24. package/dist/services/athena/athena-service.d.ts +13 -1
  25. package/dist/services/athena/athena-service.js +27 -5
  26. package/dist/services/athena/athena-service.spec.d.ts +1 -0
  27. package/dist/services/athena/athena-service.spec.js +93 -0
  28. package/dist/services/deploy/handler/install-resource-handler.js +3 -4
  29. package/dist/services/onboarding/onboarding-service-factory.js +2 -2
  30. package/dist/services/pop-config/pop-config-service.d.ts +8 -1
  31. package/dist/services/pop-config/pop-config-service.js +16 -28
  32. package/dist/services/prewarm/assets/asset-api-client.d.ts +1 -1
  33. package/dist/services/prewarm/assets/asset-api-client.js +5 -4
  34. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +20 -2
  35. package/dist/services/prewarm/auto-pre-warm-factory.js +24 -6
  36. package/dist/services/prewarm/csv-reader.d.ts +21 -2
  37. package/dist/services/prewarm/csv-reader.js +71 -9
  38. package/dist/services/prewarm/csv-reader.spec.d.ts +1 -0
  39. package/dist/services/prewarm/csv-reader.spec.js +75 -0
  40. package/dist/services/prewarm/index.d.ts +1 -0
  41. package/dist/services/prewarm/index.js +1 -0
  42. package/dist/services/prewarm/pre-warm-factory.d.ts +0 -2
  43. package/dist/services/prewarm/pre-warm-factory.js +8 -11
  44. package/dist/services/prewarm/pre-warm-model.d.ts +14 -1
  45. package/dist/services/prewarm/pre-warm-model.js +0 -1
  46. package/dist/services/prewarm/pre-warm-service.d.ts +9 -4
  47. package/dist/services/prewarm/pre-warm-service.js +18 -14
  48. package/dist/services/prewarm/prewarm-targets.d.ts +12 -0
  49. package/dist/services/prewarm/prewarm-targets.js +18 -0
  50. package/dist/services/prewarm/prewarm-targets.spec.d.ts +1 -0
  51. package/dist/services/prewarm/prewarm-targets.spec.js +29 -0
  52. package/dist/services/query-builder/error/match-matches-nothing-error.d.ts +11 -0
  53. package/dist/services/query-builder/error/match-matches-nothing-error.js +17 -0
  54. package/dist/services/query-builder/queries/device.d.ts +21 -0
  55. package/dist/services/query-builder/queries/device.js +26 -0
  56. package/dist/services/query-builder/queries/index.d.ts +6 -0
  57. package/dist/services/query-builder/queries/index.js +6 -0
  58. package/dist/services/query-builder/queries/page-filter.d.ts +39 -0
  59. package/dist/services/query-builder/queries/page-filter.js +135 -0
  60. package/dist/services/query-builder/queries/page-source.d.ts +30 -0
  61. package/dist/services/query-builder/queries/page-source.js +79 -0
  62. package/dist/services/query-builder/queries/parameter-query.d.ts +14 -0
  63. package/dist/services/query-builder/queries/parameter-query.js +142 -0
  64. package/dist/services/query-builder/queries/pops-query.d.ts +27 -0
  65. package/dist/services/query-builder/queries/pops-query.js +65 -0
  66. package/dist/services/query-builder/queries/prewarm-query.d.ts +18 -0
  67. package/dist/services/query-builder/queries/prewarm-query.js +227 -0
  68. package/dist/services/query-builder/queries/query-model.d.ts +97 -0
  69. package/dist/services/query-builder/queries/query-model.js +19 -0
  70. package/dist/services/query-builder/queries/spec/golden-configs.d.ts +18 -0
  71. package/dist/services/query-builder/queries/spec/golden-configs.js +116 -0
  72. package/dist/services/query-builder/queries/spec/golden.spec.d.ts +1 -0
  73. package/dist/services/query-builder/queries/spec/golden.spec.js +161 -0
  74. package/dist/services/query-builder/queries/spec/pops-query.spec.d.ts +1 -0
  75. package/dist/services/query-builder/queries/spec/pops-query.spec.js +58 -0
  76. package/dist/services/query-builder/queries/spec/queries.spec.d.ts +1 -0
  77. package/dist/services/query-builder/queries/spec/queries.spec.js +312 -0
  78. package/dist/services/query-builder/queries/spec/reported-bugs.spec.d.ts +1 -0
  79. package/dist/services/query-builder/queries/spec/reported-bugs.spec.js +86 -0
  80. package/dist/services/query-builder/query-builder-factory.d.ts +2 -2
  81. package/dist/services/query-builder/query-builder-factory.js +36 -8
  82. package/dist/services/query-builder/query-builder-model.d.ts +33 -19
  83. package/dist/services/query-builder/query-builder-model.js +5 -5
  84. package/dist/services/query-builder/query-builder-service.d.ts +26 -5
  85. package/dist/services/query-builder/query-builder-service.js +157 -44
  86. package/dist/services/query-builder/query-command.d.ts +27 -0
  87. package/dist/services/query-builder/query-command.js +36 -0
  88. package/dist/services/query-builder/rules/index.d.ts +8 -0
  89. package/dist/services/query-builder/rules/index.js +8 -0
  90. package/dist/services/query-builder/rules/match-pattern.d.ts +51 -0
  91. package/dist/services/query-builder/rules/match-pattern.js +141 -0
  92. package/dist/services/query-builder/rules/pattern-language.d.ts +59 -0
  93. package/dist/services/query-builder/rules/pattern-language.js +159 -0
  94. package/dist/services/query-builder/rules/rule-model.d.ts +107 -0
  95. package/dist/services/query-builder/rules/rule-model.js +54 -0
  96. package/dist/services/query-builder/rules/rule-parser.d.ts +33 -0
  97. package/dist/services/query-builder/rules/rule-parser.js +189 -0
  98. package/dist/services/query-builder/rules/rule-reducer.d.ts +71 -0
  99. package/dist/services/query-builder/rules/rule-reducer.js +383 -0
  100. package/dist/services/query-builder/rules/rule-relevance.d.ts +43 -0
  101. package/dist/services/query-builder/rules/rule-relevance.js +69 -0
  102. package/dist/services/query-builder/rules/rule-to-sql.d.ts +51 -0
  103. package/dist/services/query-builder/rules/rule-to-sql.js +75 -0
  104. package/dist/services/query-builder/rules/spec/pattern-language.spec.d.ts +1 -0
  105. package/dist/services/query-builder/rules/spec/pattern-language.spec.js +90 -0
  106. package/dist/services/query-builder/rules/spec/rule-parser.spec.d.ts +1 -0
  107. package/dist/services/query-builder/rules/spec/rule-parser.spec.js +150 -0
  108. package/dist/services/query-builder/rules/spec/rule-reducer.spec.d.ts +1 -0
  109. package/dist/services/query-builder/rules/spec/rule-reducer.spec.js +169 -0
  110. package/dist/services/query-builder/rules/spec/rule-relevance.spec.d.ts +1 -0
  111. package/dist/services/query-builder/rules/spec/rule-relevance.spec.js +88 -0
  112. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.d.ts +1 -0
  113. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.js +110 -0
  114. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.d.ts +1 -0
  115. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.js +55 -0
  116. package/dist/services/query-builder/rules/spec/strip-parameters.spec.d.ts +1 -0
  117. package/dist/services/query-builder/rules/spec/strip-parameters.spec.js +85 -0
  118. package/dist/services/query-builder/rules/strip-parameters.d.ts +39 -0
  119. package/dist/services/query-builder/rules/strip-parameters.js +197 -0
  120. package/dist/services/query-builder/sql/expression.d.ts +73 -0
  121. package/dist/services/query-builder/sql/expression.js +133 -0
  122. package/dist/services/query-builder/sql/format.d.ts +14 -0
  123. package/dist/services/query-builder/sql/format.js +48 -0
  124. package/dist/services/query-builder/sql/index.d.ts +4 -0
  125. package/dist/services/query-builder/sql/index.js +4 -0
  126. package/dist/services/query-builder/sql/predicate.d.ts +66 -0
  127. package/dist/services/query-builder/sql/predicate.js +124 -0
  128. package/dist/services/query-builder/sql/regex.d.ts +28 -0
  129. package/dist/services/query-builder/sql/regex.js +81 -0
  130. package/dist/services/query-builder/sql/spec/expression.spec.d.ts +1 -0
  131. package/dist/services/query-builder/sql/spec/expression.spec.js +75 -0
  132. package/dist/services/query-builder/sql/spec/format.spec.d.ts +1 -0
  133. package/dist/services/query-builder/sql/spec/format.spec.js +37 -0
  134. package/dist/services/query-builder/sql/spec/predicate.spec.d.ts +1 -0
  135. package/dist/services/query-builder/sql/spec/predicate.spec.js +78 -0
  136. package/dist/services/query-builder/sql/spec/regex.spec.d.ts +1 -0
  137. package/dist/services/query-builder/sql/spec/regex.spec.js +62 -0
  138. package/oclif.manifest.json +401 -100
  139. package/package.json +11 -2
  140. package/dist/commands/build-parameter-query.d.ts +0 -14
  141. package/dist/commands/build-parameter-query.js +0 -25
  142. package/dist/commands/build-prewarm-query.d.ts +0 -16
  143. package/dist/commands/build-prewarm-query.js +0 -37
  144. package/dist/helpers/build-query-helper.d.ts +0 -32
  145. package/dist/helpers/build-query-helper.js +0 -223
  146. package/dist/helpers/get-parsed-config.d.ts +0 -15
  147. package/dist/helpers/get-parsed-config.js +0 -62
  148. package/dist/services/onboarding/dashboard/parameter-query-builder.d.ts +0 -8
  149. package/dist/services/onboarding/dashboard/parameter-query-builder.js +0 -63
@@ -0,0 +1,55 @@
1
+ import { PathnameRule, testCondition } from "@speedkit/rules";
2
+ import { expect } from "chai";
3
+ import { before, describe, it } from "mocha";
4
+ import { matchesExactly } from "../rule-model.js";
5
+ /**
6
+ * The query builder mirrors decisions that `@speedkit/rules` makes in the
7
+ * browser. It cannot call into the package to build SQL, so these tests pin
8
+ * the mirrored behaviour against the package itself — if the runtime ever
9
+ * changes how a condition or a subject is read, they fail here rather than in
10
+ * a query nobody re-reads.
11
+ */
12
+ const VERSIONS = [1, 2, 3];
13
+ describe("string conditions follow the runtime", () => {
14
+ for (const version of VERSIONS) {
15
+ it(`should match exactly only where the runtime does, version ${version}`, () => {
16
+ // A subject the condition is a prefix of, but not equal to.
17
+ const runtimeMatchesPrefix = testCondition("/shoes/nike", "/shoes", version);
18
+ const runtimeMatchesExact = testCondition("/shoes", "/shoes", version);
19
+ expect(runtimeMatchesExact).to.equal(true);
20
+ expect(runtimeMatchesPrefix).to.equal(!matchesExactly(version));
21
+ });
22
+ }
23
+ it("should ignore case on both sides", () => {
24
+ expect(testCondition("/Shoes", "/shOES", 1)).to.equal(true);
25
+ expect(testCondition("/Shoes", "/shOES", 2)).to.equal(true);
26
+ });
27
+ });
28
+ describe("the pathname subject follows the runtime", () => {
29
+ // The package resolves a request URL against `location`, which only exists
30
+ // in a browser. Anything that evaluates a rule outside one has to provide it.
31
+ before(() => {
32
+ const global = globalThis;
33
+ global.location ??= new URL("https://www.shop.de/");
34
+ });
35
+ function matchesPathname(pattern, ruleSetVersion) {
36
+ return new PathnameRule(pattern).matches(new Request("https://www.shop.de/shoes/?page=2"), { ruleSetVersion });
37
+ }
38
+ it("should test a version 1 pattern against path and query", () => {
39
+ // The subject ends in `2`, not in the slash, so the anchored pattern
40
+ // misses a URL a reader would expect it to cover.
41
+ expect(matchesPathname(/\/$/, 1)).to.equal(false);
42
+ });
43
+ it("should test a version 2 pattern against the path alone", () => {
44
+ expect(matchesPathname(/\/$/, 2)).to.equal(true);
45
+ });
46
+ it("should fall back to the version 1 subject for any other version", () => {
47
+ expect(matchesPathname(/\/$/, 3)).to.equal(false);
48
+ expect(matchesExactly(3)).to.equal(false);
49
+ });
50
+ it("should match a pattern that survives both versions", () => {
51
+ const bothVersions = /^[^?]*\/(\?|$)/;
52
+ expect(matchesPathname(bothVersions, 1)).to.equal(true);
53
+ expect(matchesPathname(bothVersions, 2)).to.equal(true);
54
+ });
55
+ });
@@ -0,0 +1,85 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { column, formatPredicate } from "../../sql/index.js";
4
+ import { parseRuleSets } from "../rule-parser.js";
5
+ import { urlSubjects } from "../rule-to-sql.js";
6
+ import { notStrippedParameter, prepareStripParameters, strippedUrlExpression, } from "../strip-parameters.js";
7
+ const subjects = urlSubjects(column("url"));
8
+ function prepare(stripQueryParams, version = 2) {
9
+ return prepareStripParameters(parseRuleSets({ ruleSetConfig: { version }, stripQueryParams })
10
+ .stripQueryParams, subjects);
11
+ }
12
+ function strippedUrl(stripQueryParams, version = 2) {
13
+ return strippedUrlExpression(prepare(stripQueryParams, version), "url");
14
+ }
15
+ function keptParameter(stripQueryParams, version = 2) {
16
+ return formatPredicate(notStrippedParameter(prepare(stripQueryParams, version), "key"));
17
+ }
18
+ describe("strippedUrlExpression", () => {
19
+ it("should always remove the fragment", () => {
20
+ expect(strippedUrl([])).to.equal("regexp_replace(url, '#.*')");
21
+ });
22
+ it("should match a name exactly under version 2", () => {
23
+ expect(strippedUrl(["gclid"])).to.contain("(?i)[?&](gclid)(=[^&]*)?(?=&|$)");
24
+ });
25
+ it("should match a name as a prefix under version 1", () => {
26
+ expect(strippedUrl(["utm"], 1)).to.contain("(?i)[?&](utm)[^&]*");
27
+ });
28
+ it("should escape a name that reads as a pattern", () => {
29
+ expect(strippedUrl(["a.b"])).to.contain(String.raw `(a\.b)`);
30
+ });
31
+ it("should use the prefix form for a pattern anchored at the start", () => {
32
+ expect(strippedUrl([/^utm[_-]/i])).to.contain("(?i)[?&](utm[_-])[^&]*");
33
+ });
34
+ it("should use the suffix form for a pattern anchored at the end", () => {
35
+ expect(strippedUrl([/id$/i])).to.contain("(?i)[?&][^&]*(id)(=[^&]*)?(?=&|$)");
36
+ });
37
+ it("should keep case-sensitive patterns in their own expression", () => {
38
+ const expression = strippedUrl([/^utm/, /^UTM/i]);
39
+ expect(expression).to.contain("'[?&](utm)[^&]*'");
40
+ expect(expression).to.contain("'(?i)[?&](UTM)[^&]*'");
41
+ });
42
+ it("should merge rules that strip everywhere into one expression", () => {
43
+ const merged = strippedUrl([{ params: ["a"] }, { params: ["b"] }]);
44
+ expect(merged.match(/regexp_replace/g)).to.have.lengthOf(2);
45
+ expect(merged).to.contain("(a|b)");
46
+ });
47
+ it("should apply a scoped rule only on the pages it names", () => {
48
+ const expression = strippedUrl([
49
+ { enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
50
+ ]);
51
+ expect(expression).to.contain("if(regexp_like(");
52
+ expect(expression).to.contain("(?i)^/p/");
53
+ });
54
+ it("should leave out a rule scoped to something that is never a document", () => {
55
+ const prepared = prepare([
56
+ { params: ["size"], rules: [{ contentType: ["image"] }] },
57
+ ]);
58
+ expect(prepared.groups).to.deep.equal([]);
59
+ expect(prepared.dropped[0].source).to.equal("stripQueryParams[0]");
60
+ });
61
+ });
62
+ describe("notStrippedParameter", () => {
63
+ it("should keep every parameter when nothing is stripped", () => {
64
+ expect(keptParameter([])).to.equal("true");
65
+ });
66
+ it("should reject a stripped name exactly under version 2", () => {
67
+ expect(keptParameter(["gclid"])).to.equal("not (lower(key) = 'gclid')");
68
+ });
69
+ it("should reject a stripped name as a prefix under version 1", () => {
70
+ expect(keptParameter(["utm"], 1)).to.equal("not (lower(key) like 'utm%')");
71
+ });
72
+ it("should reject every stripped name", () => {
73
+ expect(keptParameter(["a", "b"])).to.equal("not (lower(key) = 'a')\nand not (lower(key) = 'b')");
74
+ });
75
+ it("should reject a scoped name only on its own pages", () => {
76
+ expect(keptParameter([
77
+ { enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
78
+ ])).to.equal([
79
+ "not (",
80
+ " regexp_like(coalesce(nullif(url_extract_path(url), ''), '/'), '(?i)^/p/')",
81
+ " and lower(key) = 'size'",
82
+ ")",
83
+ ].join("\n"));
84
+ });
85
+ });
@@ -0,0 +1,39 @@
1
+ import { Predicate, SqlExpression } from "../sql/index.js";
2
+ import { ConditionPattern, StripParameterRule } from "./rule-model.js";
3
+ import { RelevanceContext, RuleNote } from "./rule-relevance.js";
4
+ import { RuleSubjectExpressions } from "./rule-to-sql.js";
5
+ /** Parameters that are stripped on the pages a scope predicate selects. */
6
+ export interface StripParameterGroup {
7
+ readonly params: readonly ConditionPattern[];
8
+ readonly ruleSetVersion: number;
9
+ /** `TRUE` when the parameters are stripped everywhere. */
10
+ readonly scope: Predicate;
11
+ readonly sources: string[];
12
+ }
13
+ export interface PreparedStripParameters {
14
+ /** Strip rules that cannot apply to a page impression. */
15
+ readonly dropped: RuleNote[];
16
+ readonly groups: StripParameterGroup[];
17
+ readonly warnings: string[];
18
+ }
19
+ /**
20
+ * Turns the `stripQueryParams` rules into the groups the query needs.
21
+ *
22
+ * Rules that strip everywhere are merged into a single group so their
23
+ * parameters share one set of expressions, which is what the emitted query
24
+ * looked like before scoping was understood. A rule limited to certain pages
25
+ * or resources keeps its own group and carries the predicate that selects
26
+ * them; one limited to resources that are never documents is dropped.
27
+ */
28
+ export declare function prepareStripParameters(rules: readonly StripParameterRule[], subjects: RuleSubjectExpressions, context?: RelevanceContext): PreparedStripParameters;
29
+ /**
30
+ * The URL with every stripped parameter and the fragment removed — the cache
31
+ * key Speed Kit would use. Built as nested `regexp_replace` calls, one per
32
+ * kind of parameter pattern.
33
+ */
34
+ export declare function strippedUrlExpression(prepared: PreparedStripParameters, url: SqlExpression, warnings?: string[]): SqlExpression;
35
+ /**
36
+ * Holds for a parameter name that Speed Kit keeps — the filter the parameter
37
+ * analysis applies to every key of a URL.
38
+ */
39
+ export declare function notStrippedParameter(prepared: PreparedStripParameters, key: SqlExpression, warnings?: string[]): Predicate;
@@ -0,0 +1,197 @@
1
+ import { and, call, equalsIgnoreCase, escapeRegexLiteral, formatPredicate, isTrue, literal, not, or, regexpLike, startsWith, toAthenaRegex, TRUE, } from "../sql/index.js";
2
+ import { matchesExactly, } from "./rule-model.js";
3
+ import { keepRelevantRules, } from "./rule-relevance.js";
4
+ import { translateRules } from "./rule-to-sql.js";
5
+ /**
6
+ * Turns the `stripQueryParams` rules into the groups the query needs.
7
+ *
8
+ * Rules that strip everywhere are merged into a single group so their
9
+ * parameters share one set of expressions, which is what the emitted query
10
+ * looked like before scoping was understood. A rule limited to certain pages
11
+ * or resources keeps its own group and carries the predicate that selects
12
+ * them; one limited to resources that are never documents is dropped.
13
+ */
14
+ export function prepareStripParameters(rules, subjects, context = {}) {
15
+ const dropped = [];
16
+ const warnings = [];
17
+ const groups = [];
18
+ // Groups the rules stripping everywhere are merged into, one per version.
19
+ const unscoped = new Map();
20
+ for (const rule of rules) {
21
+ const page = scopeOf(rule.enabledSites, subjects, context, warnings);
22
+ const resource = scopeOf(rule.rules, subjects, context, warnings);
23
+ if (!page || !resource) {
24
+ dropped.push({
25
+ reason: "strips parameters only on pages or resources a page impression can never be",
26
+ source: rule.source,
27
+ });
28
+ continue;
29
+ }
30
+ const scope = and(page, resource);
31
+ const merged = isTrue(scope)
32
+ ? unscoped.get(rule.ruleSetVersion)
33
+ : undefined;
34
+ if (merged) {
35
+ merged.params.push(...rule.params);
36
+ merged.sources.push(rule.source);
37
+ continue;
38
+ }
39
+ // The group holds the very arrays the map keeps, so a later rule that
40
+ // strips everywhere too extends this group instead of adding another.
41
+ const group = {
42
+ params: [...rule.params],
43
+ ruleSetVersion: rule.ruleSetVersion,
44
+ scope,
45
+ sources: [rule.source],
46
+ };
47
+ groups.push(group);
48
+ if (isTrue(scope)) {
49
+ unscoped.set(rule.ruleSetVersion, group);
50
+ }
51
+ }
52
+ return { dropped, groups, warnings };
53
+ }
54
+ /**
55
+ * The URL with every stripped parameter and the fragment removed — the cache
56
+ * key Speed Kit would use. Built as nested `regexp_replace` calls, one per
57
+ * kind of parameter pattern.
58
+ */
59
+ export function strippedUrlExpression(prepared, url, warnings = []) {
60
+ let expression = call("regexp_replace", url, literal("#.*"));
61
+ for (const group of prepared.groups) {
62
+ const replaced = replaceParameters(group, expression, warnings);
63
+ expression = isTrue(group.scope)
64
+ ? replaced
65
+ : // Commas separate the arguments, and `and` binds tighter, so the
66
+ // predicate needs no parentheses of its own here.
67
+ call("if", formatPredicate(group.scope), replaced, expression);
68
+ }
69
+ return expression;
70
+ }
71
+ /**
72
+ * Holds for a parameter name that Speed Kit keeps — the filter the parameter
73
+ * analysis applies to every key of a URL.
74
+ */
75
+ export function notStrippedParameter(prepared, key, warnings = []) {
76
+ return and(...prepared.groups.map((group) => {
77
+ const matches = group.params.map((pattern) => matchesParameter(pattern, key, group.ruleSetVersion, warnings));
78
+ return isTrue(group.scope)
79
+ ? and(...matches.map((match) => not(match)))
80
+ : not(and(group.scope, or(...matches)));
81
+ }));
82
+ }
83
+ /**
84
+ * The predicate selecting the pages or resources a strip rule is scoped to,
85
+ * or `undefined` when the scope was given but can never hold for a document.
86
+ */
87
+ function scopeOf(rules, subjects, context, warnings) {
88
+ if (!rules || rules.length === 0) {
89
+ return TRUE;
90
+ }
91
+ const relevant = keepRelevantRules(rules, context);
92
+ if (relevant.rules.length === 0) {
93
+ return undefined;
94
+ }
95
+ const { predicate, warnings: ruleWarnings } = translateRules(relevant.rules, subjects);
96
+ warnings.push(...ruleWarnings);
97
+ return predicate;
98
+ }
99
+ function matchesParameter(pattern, key, ruleSetVersion, warnings) {
100
+ if (pattern.kind === "regexp") {
101
+ const athenaRegex = toAthenaRegex(pattern.value);
102
+ warnings.push(...athenaRegex.warnings.map((warning) => `stripQueryParams: ${warning}`));
103
+ return regexpLike(key, athenaRegex.pattern);
104
+ }
105
+ // A parameter name is a string condition: case-insensitive, and a prefix
106
+ // until the config moves to rule-set version 2.
107
+ return matchesExactly(ruleSetVersion)
108
+ ? equalsIgnoreCase(key, pattern.value)
109
+ : startsWith(key, pattern.value);
110
+ }
111
+ /**
112
+ * Nests one `regexp_replace` per pattern kind around the given expression.
113
+ *
114
+ * A parameter is removed together with its value, and where in the name the
115
+ * pattern has to match depends on how it is anchored: `/^utm/` covers every
116
+ * name starting with `utm`, `/id$/` every name ending in it, a plain name
117
+ * matches the whole key. Case-insensitive patterns need their own expression
118
+ * because the flag applies to the whole alternation.
119
+ */
120
+ function replaceParameters(group, expression, warnings) {
121
+ const buckets = bucketParameters(group, warnings);
122
+ const patterns = [
123
+ prefixPattern(buckets.prefix, false),
124
+ prefixPattern(buckets.prefixIgnoreCase, true),
125
+ suffixPattern(buckets.suffix, false),
126
+ suffixPattern(buckets.suffixIgnoreCase, true),
127
+ fullPattern(buckets.full, false),
128
+ fullPattern(buckets.fullIgnoreCase, true),
129
+ ];
130
+ let replaced = expression;
131
+ for (const pattern of patterns) {
132
+ if (pattern) {
133
+ replaced = call("regexp_replace", replaced, literal(pattern), literal(""));
134
+ }
135
+ }
136
+ return replaced;
137
+ }
138
+ function bucketParameters(group, warnings) {
139
+ const buckets = {
140
+ full: [],
141
+ fullIgnoreCase: [],
142
+ prefix: [],
143
+ prefixIgnoreCase: [],
144
+ suffix: [],
145
+ suffixIgnoreCase: [],
146
+ };
147
+ for (const pattern of group.params) {
148
+ if (pattern.kind === "string") {
149
+ // A string is matched case-insensitively, so it belongs to the
150
+ // ignore-case bucket, and version 1 treats it as a prefix.
151
+ const name = escapeRegexLiteral(pattern.value);
152
+ if (matchesExactly(group.ruleSetVersion)) {
153
+ buckets.fullIgnoreCase.push(name);
154
+ }
155
+ else {
156
+ buckets.prefixIgnoreCase.push(name);
157
+ }
158
+ continue;
159
+ }
160
+ const { pattern: athenaPattern, warnings: patternWarnings } = toAthenaRegex(stripFlags(pattern.value));
161
+ warnings.push(...patternWarnings.map((warning) => `stripQueryParams: ${warning}`));
162
+ const ignoreCase = pattern.value.ignoreCase;
163
+ const anchoredStart = athenaPattern.startsWith("^");
164
+ const anchoredEnd = /(?<!\\)\$$/.test(athenaPattern);
165
+ const inner = athenaPattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : undefined);
166
+ if (anchoredStart && !anchoredEnd) {
167
+ (ignoreCase ? buckets.prefixIgnoreCase : buckets.prefix).push(inner);
168
+ continue;
169
+ }
170
+ if (anchoredEnd && !anchoredStart) {
171
+ (ignoreCase ? buckets.suffixIgnoreCase : buckets.suffix).push(inner);
172
+ continue;
173
+ }
174
+ // Anchored on both sides, or on neither: both mean the whole name.
175
+ (ignoreCase ? buckets.fullIgnoreCase : buckets.full).push(inner);
176
+ }
177
+ return buckets;
178
+ }
179
+ /** The pattern without its flags — they are applied to the whole alternation. */
180
+ function stripFlags(regExp) {
181
+ return new RegExp(regExp.source);
182
+ }
183
+ function prefixPattern(names, ignoreCase) {
184
+ return names.length > 0
185
+ ? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})[^&]*`
186
+ : null;
187
+ }
188
+ function suffixPattern(names, ignoreCase) {
189
+ return names.length > 0
190
+ ? `${ignoreCase ? "(?i)" : ""}[?&][^&]*(${names.join("|")})(=[^&]*)?(?=&|$)`
191
+ : null;
192
+ }
193
+ function fullPattern(names, ignoreCase) {
194
+ return names.length > 0
195
+ ? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})(=[^&]*)?(?=&|$)`
196
+ : null;
197
+ }
@@ -0,0 +1,73 @@
1
+ import { Predicate } from "./predicate.js";
2
+ /**
3
+ * A scalar SQL expression. Always build one through the functions in this
4
+ * module: they escape their operands, which is what keeps a config pattern
5
+ * containing a quote from producing broken SQL.
6
+ */
7
+ export type SqlExpression = string;
8
+ /**
9
+ * A string, number or boolean as a SQL literal. Quotes inside a string are
10
+ * doubled, so any config-provided value is safe to embed.
11
+ */
12
+ export declare function literal(value: boolean | number | string): SqlExpression;
13
+ /**
14
+ * A column reference, optionally qualified (`cdnclient.bot`). Rejects anything
15
+ * that is not an identifier, so a column name can never smuggle in SQL.
16
+ */
17
+ export declare function column(name: string): SqlExpression;
18
+ /** A function call with the given arguments, e.g. `url_extract_path(url)`. */
19
+ export declare function call(name: string, ...args: SqlExpression[]): SqlExpression;
20
+ /** `lower(expression)`, used to match the case-insensitive string conditions. */
21
+ export declare function lower(expression: SqlExpression): SqlExpression;
22
+ /** Concatenation of all expressions, parenthesised so it can be nested. */
23
+ export declare function concat(...expressions: SqlExpression[]): SqlExpression;
24
+ /**
25
+ * Wraps a hand-written expression. The caller is responsible for escaping its
26
+ * operands and for parenthesising anything that binds loosely.
27
+ */
28
+ export declare function raw(sql: string): SqlExpression;
29
+ /** `left = right`. */
30
+ export declare function equals(left: SqlExpression, right: SqlExpression): Predicate;
31
+ /** `left <> right`. */
32
+ export declare function notEquals(left: SqlExpression, right: SqlExpression): Predicate;
33
+ /**
34
+ * A placeholder for a value passed to Athena alongside the query.
35
+ *
36
+ * Placeholders are bound by position, in the order they appear in the printed
37
+ * SQL — so a predicate must not contain two identical comparisons over
38
+ * placeholders: {@link and} and {@link or} drop a duplicate operand, which
39
+ * would leave one value too many.
40
+ */
41
+ export declare function parameter(): SqlExpression;
42
+ /** `left >= right`. */
43
+ export declare function greaterOrEqual(left: SqlExpression, right: SqlExpression): Predicate;
44
+ /** `left > right`. */
45
+ export declare function greaterThan(left: SqlExpression, right: SqlExpression): Predicate;
46
+ /**
47
+ * `subject = 'value'`, ignoring case on both sides — the semantics of a string
48
+ * condition in a Speed Kit rule set of version 2.
49
+ */
50
+ export declare function equalsIgnoreCase(subject: SqlExpression, value: string): Predicate;
51
+ /**
52
+ * `subject like 'value%'` — a prefix match, which is what a string condition
53
+ * means in a rule set of version 1. Case is ignored by default, matching the
54
+ * runtime, which lower-cases both sides.
55
+ */
56
+ export declare function startsWith(subject: SqlExpression, value: string, options?: {
57
+ ignoreCase?: boolean;
58
+ }): Predicate;
59
+ /** `subject between low and high`. */
60
+ export declare function between(subject: SqlExpression, low: SqlExpression, high: SqlExpression): Predicate;
61
+ /** `expression is null`. */
62
+ export declare function isNull(expression: SqlExpression): Predicate;
63
+ /** `subject not in ('a', 'b')`. */
64
+ export declare function notInValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
65
+ /** `subject in ('a', 'b')`. */
66
+ export declare function inValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
67
+ /**
68
+ * `regexp_like(subject, 'pattern')`. The pattern is expected to already be in
69
+ * Athena's dialect — see {@link toAthenaRegex}.
70
+ */
71
+ export declare function regexpLike(subject: SqlExpression, pattern: string): Predicate;
72
+ /** A boolean column used as a predicate on its own, e.g. `suspicious`. */
73
+ export declare function booleanColumn(name: string): Predicate;
@@ -0,0 +1,133 @@
1
+ import { leaf } from "./predicate.js";
2
+ const IDENTIFIER = /^[a-z_][\w$]*(\.[a-z_][\w$]*)*$/i;
3
+ /** Characters that carry a meaning inside a `like` pattern. */
4
+ const LIKE_METACHARACTERS = /[%_\\]/;
5
+ /**
6
+ * A string, number or boolean as a SQL literal. Quotes inside a string are
7
+ * doubled, so any config-provided value is safe to embed.
8
+ */
9
+ export function literal(value) {
10
+ if (typeof value === "boolean") {
11
+ return value ? "true" : "false";
12
+ }
13
+ if (typeof value === "number") {
14
+ if (!Number.isFinite(value)) {
15
+ throw new Error(`Cannot use ${value} as a SQL literal`);
16
+ }
17
+ return String(value);
18
+ }
19
+ return `'${value.replaceAll("'", "''")}'`;
20
+ }
21
+ /**
22
+ * A column reference, optionally qualified (`cdnclient.bot`). Rejects anything
23
+ * that is not an identifier, so a column name can never smuggle in SQL.
24
+ */
25
+ export function column(name) {
26
+ if (!IDENTIFIER.test(name)) {
27
+ throw new Error(`Not a valid column name: ${name}`);
28
+ }
29
+ return name;
30
+ }
31
+ /** A function call with the given arguments, e.g. `url_extract_path(url)`. */
32
+ export function call(name, ...args) {
33
+ if (!IDENTIFIER.test(name)) {
34
+ throw new Error(`Not a valid function name: ${name}`);
35
+ }
36
+ return `${name}(${args.join(", ")})`;
37
+ }
38
+ /** `lower(expression)`, used to match the case-insensitive string conditions. */
39
+ export function lower(expression) {
40
+ return call("lower", expression);
41
+ }
42
+ /** Concatenation of all expressions, parenthesised so it can be nested. */
43
+ export function concat(...expressions) {
44
+ return `(${expressions.join(" || ")})`;
45
+ }
46
+ /**
47
+ * Wraps a hand-written expression. The caller is responsible for escaping its
48
+ * operands and for parenthesising anything that binds loosely.
49
+ */
50
+ export function raw(sql) {
51
+ return sql;
52
+ }
53
+ /** `left = right`. */
54
+ export function equals(left, right) {
55
+ return leaf(`${left} = ${right}`);
56
+ }
57
+ /** `left <> right`. */
58
+ export function notEquals(left, right) {
59
+ return leaf(`${left} <> ${right}`);
60
+ }
61
+ /**
62
+ * A placeholder for a value passed to Athena alongside the query.
63
+ *
64
+ * Placeholders are bound by position, in the order they appear in the printed
65
+ * SQL — so a predicate must not contain two identical comparisons over
66
+ * placeholders: {@link and} and {@link or} drop a duplicate operand, which
67
+ * would leave one value too many.
68
+ */
69
+ export function parameter() {
70
+ return "?";
71
+ }
72
+ /** `left >= right`. */
73
+ export function greaterOrEqual(left, right) {
74
+ return leaf(`${left} >= ${right}`);
75
+ }
76
+ /** `left > right`. */
77
+ export function greaterThan(left, right) {
78
+ return leaf(`${left} > ${right}`);
79
+ }
80
+ /**
81
+ * `subject = 'value'`, ignoring case on both sides — the semantics of a string
82
+ * condition in a Speed Kit rule set of version 2.
83
+ */
84
+ export function equalsIgnoreCase(subject, value) {
85
+ return equals(lower(subject), literal(value.toLowerCase()));
86
+ }
87
+ /**
88
+ * `subject like 'value%'` — a prefix match, which is what a string condition
89
+ * means in a rule set of version 1. Case is ignored by default, matching the
90
+ * runtime, which lower-cases both sides.
91
+ */
92
+ export function startsWith(subject, value, options = {}) {
93
+ const ignoreCase = options.ignoreCase ?? true;
94
+ const prefix = ignoreCase ? value.toLowerCase() : value;
95
+ const left = ignoreCase ? lower(subject) : subject;
96
+ if (!LIKE_METACHARACTERS.test(prefix)) {
97
+ return leaf(`${left} like ${literal(`${prefix}%`)}`);
98
+ }
99
+ // `like` has no escape character unless one is declared, so a value
100
+ // containing `%`, `_` or `\` needs both the escaping and the declaration.
101
+ const escaped = prefix.replaceAll(/[%_\\]/g, (match) => `\\${match}`);
102
+ return leaf(`${left} like ${literal(`${escaped}%`)} escape '\\'`);
103
+ }
104
+ /** `subject between low and high`. */
105
+ export function between(subject, low, high) {
106
+ return leaf(`${subject} between ${low} and ${high}`);
107
+ }
108
+ /** `expression is null`. */
109
+ export function isNull(expression) {
110
+ return leaf(`${expression} is null`);
111
+ }
112
+ /** `subject not in ('a', 'b')`. */
113
+ export function notInValues(subject, values) {
114
+ return leaf(`${subject} not in (${valueList(values)})`);
115
+ }
116
+ /** `subject in ('a', 'b')`. */
117
+ export function inValues(subject, values) {
118
+ return leaf(`${subject} in (${valueList(values)})`);
119
+ }
120
+ function valueList(values) {
121
+ return values.map((value) => literal(value)).join(", ");
122
+ }
123
+ /**
124
+ * `regexp_like(subject, 'pattern')`. The pattern is expected to already be in
125
+ * Athena's dialect — see {@link toAthenaRegex}.
126
+ */
127
+ export function regexpLike(subject, pattern) {
128
+ return leaf(call("regexp_like", subject, literal(pattern)), true);
129
+ }
130
+ /** A boolean column used as a predicate on its own, e.g. `suspicious`. */
131
+ export function booleanColumn(name) {
132
+ return leaf(column(name), true);
133
+ }
@@ -0,0 +1,14 @@
1
+ import { Predicate } from "./predicate.js";
2
+ export interface FormatPredicateOptions {
3
+ /**
4
+ * Indentation level the predicate starts at. The first line is *not*
5
+ * indented — the caller has already positioned it (e.g. after `where `) —
6
+ * every following line is.
7
+ */
8
+ readonly level?: number;
9
+ }
10
+ /**
11
+ * Renders a predicate as SQL that a human can read in the Athena console: one
12
+ * operand per line, the operator leading the line, nested groups indented.
13
+ */
14
+ export declare function formatPredicate(predicate: Predicate, options?: FormatPredicateOptions): string;
@@ -0,0 +1,48 @@
1
+ /** Two spaces per level, matching the indentation of the emitted queries. */
2
+ const INDENT = " ";
3
+ /**
4
+ * Renders a predicate as SQL that a human can read in the Athena console: one
5
+ * operand per line, the operator leading the line, nested groups indented.
6
+ */
7
+ export function formatPredicate(predicate, options = {}) {
8
+ return print(predicate, options.level ?? 0);
9
+ }
10
+ function print(predicate, level) {
11
+ switch (predicate.kind) {
12
+ case "and":
13
+ case "or": {
14
+ const separator = `\n${INDENT.repeat(level)}${predicate.kind} `;
15
+ return predicate.children
16
+ .map((child) => printOperand(child, level, false))
17
+ .join(separator);
18
+ }
19
+ case "false": {
20
+ return "false";
21
+ }
22
+ case "leaf": {
23
+ return predicate.sql;
24
+ }
25
+ case "not": {
26
+ return `not ${printOperand(predicate.child, level, true)}`;
27
+ }
28
+ case "true": {
29
+ return "true";
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * Prints an operand of `and`, `or` or `not`. Groups get parentheses and their
35
+ * own indentation level. A leaf goes inline; a negated comparison is
36
+ * parenthesised, because `not` binding looser than `=` is a detail a reader
37
+ * should not have to remember.
38
+ */
39
+ function printOperand(predicate, level, negated) {
40
+ if (predicate.kind === "leaf") {
41
+ return negated && !predicate.atomic ? `(${predicate.sql})` : predicate.sql;
42
+ }
43
+ if (predicate.kind !== "and" && predicate.kind !== "or") {
44
+ return print(predicate, level);
45
+ }
46
+ const inner = INDENT.repeat(level + 1);
47
+ return `(\n${inner}${print(predicate, level + 1)}\n${INDENT.repeat(level)})`;
48
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./expression.js";
2
+ export * from "./format.js";
3
+ export * from "./predicate.js";
4
+ export * from "./regex.js";
@@ -0,0 +1,4 @@
1
+ export * from "./expression.js";
2
+ export * from "./format.js";
3
+ export * from "./predicate.js";
4
+ export * from "./regex.js";