@agent-native/core-corpus 0.201.1 → 0.201.2-nightly-20261006010750

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 (46) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +5 -0
  3. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +27 -1
  4. package/corpus/templates/analytics/changelog/2026-10-02-dashboard-queries-preserve-recording-permissions-across-supp.md +6 -0
  5. package/corpus/templates/analytics/changelog/2026-10-05-agent-links-copied-from-a-dashboard-analysis-or-session-repl.md +6 -0
  6. package/corpus/templates/analytics/changelog/2026-10-05-bigquery-dashboard-filters-work-with-values-that-contain-apo.md +6 -0
  7. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +2 -0
  8. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +207 -206
  9. package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +42 -4
  10. package/corpus/templates/analytics/server/lib/first-party-analytics-sql-policy.ts +261 -0
  11. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +253 -528
  12. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +1 -1
  13. package/corpus/templates/clips/changelog/2026-10-05-agent-links-copied-from-a-clip-are-shorter-so-claude-can-fet.md +6 -0
  14. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -4
  15. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +10 -5
  16. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +6 -0
  17. package/corpus/templates/content/app/components/editor/CommentRow.tsx +49 -27
  18. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +13 -8
  19. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +23 -4
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -2
  21. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +17 -0
  22. package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +156 -1
  23. package/corpus/templates/content/app/components/editor/database/DatabaseTableGrid.tsx +10 -4
  24. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +146 -78
  25. package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +1 -2
  26. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +14 -8
  27. package/corpus/templates/content/app/components/editor/suggestions/model.ts +50 -5
  28. package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +107 -22
  29. package/corpus/templates/content/changelog/2026-10-02-collections-fit-phones-tablets-and-open-side-panels-view-tab.md +6 -0
  30. package/corpus/templates/content/changelog/2026-10-02-comment-and-suggestion-headers-keep-the-author-s-name-and-ti.md +6 -0
  31. package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +1 -1
  32. package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +1 -1
  33. package/corpus/templates/content/changelog/2026-10-02-you-and-agents-can-now-suggest-edits-to-the-text-inside-tabl.md +6 -0
  34. package/corpus/templates/content/changelog/2026-10-05-agent-links-copied-from-a-document-are-shorter-so-claude-can.md +6 -0
  35. package/corpus/templates/content/changelog/2026-10-05-long-comments-now-scroll-inside-their-card-instead-of-jumpin.md +6 -0
  36. package/corpus/templates/content/changelog/2026-10-05-suggested-edits-that-rewrite-a-phrase-show-one-replacement.md +6 -0
  37. package/corpus/templates/content/server/lib/content-trash-purge.ts +10 -4
  38. package/corpus/templates/content/server/lib/suggested-edits.ts +44 -11
  39. package/corpus/templates/content/server/routes/api/_agent-native-background/content-trash-purge-worker.post.ts +6 -5
  40. package/corpus/templates/content/shared/suggestion-diff.ts +164 -49
  41. package/corpus/templates/content/shared/suggestion-formatting.ts +176 -44
  42. package/corpus/templates/content/shared/suggestion-rebase.ts +152 -19
  43. package/corpus/templates/design/changelog/2026-10-05-agent-links-copied-from-a-design-are-shorter-so-claude-can-f.md +6 -0
  44. package/corpus/templates/dispatch/changelog/2026-10-05-restored-default-apps-in-dispatch-and-kept-their-links-on-th.md +6 -0
  45. package/corpus/templates/slides/changelog/2026-10-05-agent-links-copied-from-a-deck-are-shorter-so-claude-can-fet.md +6 -0
  46. package/package.json +1 -1
@@ -1,5 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
+ import {
4
+ lexAgentSql,
5
+ readAgentSqlQuery,
6
+ rewriteAgentSqlQuerySources,
7
+ type AgentSqlDialect,
8
+ } from "@agent-native/core/agent-sql";
3
9
  import { getDbExec } from "@agent-native/core/db";
4
10
  import { getOrgSetting } from "@agent-native/core/settings";
5
11
 
@@ -9,6 +15,7 @@ import {
9
15
  type BigQueryTableRef,
10
16
  } from "./bigquery.js";
11
17
  import { requireRequestCredentialContext } from "./credentials-context.js";
18
+ import { validateAnalyticsSqlFunctions } from "./first-party-analytics-sql-policy.js";
12
19
  import { fetchGoogleWithRetry, getAccessToken } from "./gcloud.js";
13
20
  import {
14
21
  getScopedSettingRecord,
@@ -88,11 +95,6 @@ interface FirstPartyAnalyticsEventRow {
88
95
 
89
96
  const TABLE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
90
97
  const PROJECT_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]{4,61}[A-Za-z0-9]$/;
91
- const FIRST_PARTY_QUERY_TABLES = [
92
- "analytics_events",
93
- "analytics_event_daily_rollups",
94
- "analytics_user_days",
95
- ] as const;
96
98
  const BACKEND_CONFIG_CACHE_TTL_MS = 30_000;
97
99
  const MAX_BACKFILL_BATCH_SIZE = 750;
98
100
  const MAX_INSERT_BATCH_SIZE = 200;
@@ -680,102 +682,76 @@ export async function insertFirstPartyAnalyticsRows(
680
682
 
681
683
  function sqlLiteral(value: string | null): string {
682
684
  if (value === null) return "NULL";
683
- return `'${value.replace(/'/g, "''")}'`;
684
- }
685
-
686
- function readSqlDollarQuoteDelimiter(
687
- sql: string,
688
- start: number,
689
- ): string | null {
690
- if (sql[start] !== "$") return null;
691
- const firstTagCharacter = sql[start + 1];
692
- if (firstTagCharacter === "$") return "$$";
693
- if (!firstTagCharacter || !/[A-Za-z_]/.test(firstTagCharacter)) return null;
694
-
695
- let end = start + 2;
696
- while (end < sql.length && /[A-Za-z0-9_]/.test(sql[end] ?? "")) end++;
697
- return sql[end] === "$" ? sql.slice(start, end + 1) : null;
685
+ const escapes: Record<string, string> = {
686
+ "\\": "\\\\",
687
+ "'": "\\'",
688
+ "\n": "\\n",
689
+ "\r": "\\r",
690
+ "\t": "\\t",
691
+ "\b": "\\b",
692
+ "\f": "\\f",
693
+ };
694
+ return `'${value.replace(
695
+ /[\\'\x00-\x1f\x7f]/g,
696
+ (character) =>
697
+ escapes[character] ??
698
+ `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`,
699
+ )}'`;
698
700
  }
699
701
 
700
- function readSqlQuotedEnd(
701
- sql: string,
702
- start: number,
703
- quote: "'" | '"' | "`",
704
- allowBackslashEscapes = false,
705
- ): number {
706
- let index = start + 1;
707
- while (index < sql.length) {
708
- const character = sql[index];
709
- if (allowBackslashEscapes && character === "\\") {
710
- index += 2;
711
- continue;
702
+ function normalizePostgresSql(sql: string): string {
703
+ let cursor = 0;
704
+ let result = "";
705
+ const tokens = lexAgentSql(sql, { dialect: "postgres" });
706
+ for (const [index, token] of tokens.entries()) {
707
+ if (token.kind === "string" && !token.text.startsWith("'")) {
708
+ const prefix = token.text.startsWith("$")
709
+ ? "dollar-quoted"
710
+ : token.text[0];
711
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
712
+ `a PostgreSQL ${prefix} string literal`,
713
+ `First-party BigQuery query does not support PostgreSQL ${prefix} string literals`,
714
+ );
712
715
  }
713
- if (character === quote) {
714
- if (sql[index + 1] === quote) {
715
- index += 2;
716
- continue;
717
- }
718
- return index + 1;
716
+ const previous = tokens[index - 1];
717
+ if (
718
+ token.kind === "string" &&
719
+ previous?.kind === "word" &&
720
+ previous.end === token.start
721
+ ) {
722
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
723
+ "a string literal without a preceding separator",
724
+ "First-party BigQuery query requires a separator before string literals",
725
+ );
719
726
  }
720
- index++;
727
+ // PostgreSQL nests block comments; GoogleSQL does not. Remove the skipped
728
+ // spans before translating so inner comment terminators cannot expose SQL.
729
+ result +=
730
+ sql.slice(cursor, token.start).replace(/[^\t\n\r\f ]/g, " ") + token.text;
731
+ cursor = token.end;
721
732
  }
722
- return sql.length;
733
+ return result + sql.slice(cursor).replace(/[^\t\n\r\f ]/g, " ");
723
734
  }
724
735
 
725
736
  function bindSqlArguments(sql: string, args: Array<string | null>): string {
726
737
  let nextPositionalIndex = 0;
727
- let index = 0;
738
+ let cursor = 0;
728
739
  let result = "";
729
-
730
- while (index < sql.length) {
731
- const character = sql[index];
732
- const nextCharacter = sql[index + 1];
733
-
734
- if (character === "'" || character === '"' || character === "`") {
735
- const isEscapeString =
736
- character === "'" &&
737
- /[eE]/.test(sql[index - 1] ?? "") &&
738
- !/[A-Za-z0-9_]/.test(sql[index - 2] ?? "");
739
- const end = readSqlQuotedEnd(sql, index, character, isEscapeString);
740
- result += sql.slice(index, end);
741
- index = end;
742
- continue;
743
- }
744
-
745
- if (character === "-" && nextCharacter === "-") {
746
- const lineEnd = sql.indexOf("\n", index + 2);
747
- const end = lineEnd === -1 ? sql.length : lineEnd;
748
- result += sql.slice(index, end);
749
- index = end;
750
- continue;
751
- }
752
-
753
- if (character === "/" && nextCharacter === "*") {
754
- const commentEnd = sql.indexOf("*/", index + 2);
755
- const end = commentEnd === -1 ? sql.length : commentEnd + 2;
756
- result += sql.slice(index, end);
757
- index = end;
758
- continue;
759
- }
760
-
761
- const dollarQuoteDelimiter = readSqlDollarQuoteDelimiter(sql, index);
762
- if (dollarQuoteDelimiter) {
763
- const bodyEnd = sql.indexOf(
764
- dollarQuoteDelimiter,
765
- index + dollarQuoteDelimiter.length,
766
- );
767
- const end =
768
- bodyEnd === -1 ? sql.length : bodyEnd + dollarQuoteDelimiter.length;
769
- result += sql.slice(index, end);
770
- index = end;
771
- continue;
772
- }
773
-
774
- const explicitBind =
775
- character === "$" ? /^\$(\d+)/.exec(sql.slice(index)) : null;
776
- if (explicitBind || character === "?") {
777
- const placeholder = explicitBind?.[0] ?? "?";
778
- const explicitIndex = explicitBind?.[1];
740
+ for (const token of lexAgentSql(sql, { dialect: "postgres" })) {
741
+ result += sql.slice(cursor, token.start);
742
+ if (token.kind === "string") {
743
+ result += sqlLiteral(token.text.slice(1, -1).replace(/''/g, "'"));
744
+ } else if (token.kind === "quoted-identifier") {
745
+ if (/[`\\]/.test(token.value)) {
746
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
747
+ "a PostgreSQL identifier containing a backtick or backslash",
748
+ "First-party BigQuery query cannot translate identifiers containing backticks or backslashes",
749
+ );
750
+ }
751
+ result += `\`${token.value}\``;
752
+ } else if (token.kind === "parameter") {
753
+ const placeholder = token.text;
754
+ const explicitIndex = /^\$(\d+)$/.exec(placeholder)?.[1];
779
755
  const bindIndex = explicitIndex
780
756
  ? Number(explicitIndex) - 1
781
757
  : nextPositionalIndex++;
@@ -791,38 +767,65 @@ function bindSqlArguments(sql: string, args: Array<string | null>): string {
791
767
  );
792
768
  }
793
769
  result += sqlLiteral(value);
794
- index += placeholder.length;
795
- continue;
770
+ } else {
771
+ result += token.text;
796
772
  }
797
-
798
- result += character;
799
- index++;
773
+ cursor = token.end;
774
+ }
775
+ return result + sql.slice(cursor);
776
+ }
777
+
778
+ function assertBigQuerySourceProvenance(
779
+ postgresSql: string,
780
+ bigQuerySql: string,
781
+ ): void {
782
+ const postgresQuery = readAgentSqlQuery(postgresSql, { dialect: "postgres" });
783
+ const bigQuery = readAgentSqlQuery(bigQuerySql, { dialect: "bigquery" });
784
+ const fold = (name: string) =>
785
+ name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
786
+ const cteNames = new Map<string, string>();
787
+ let cteCollision = false;
788
+ for (const cte of postgresQuery.ctes) {
789
+ const key = fold(cte.name);
790
+ const previous = cteNames.get(key);
791
+ if (previous !== undefined && previous !== cte.name) cteCollision = true;
792
+ cteNames.set(key, cte.name);
793
+ }
794
+ const changedSource = postgresQuery.sources.some((source, index) => {
795
+ const converted = bigQuery.sources[index];
796
+ return (
797
+ !converted ||
798
+ source.cte !== converted.cte ||
799
+ source.quoted !== converted.quoted ||
800
+ source.name !== (source.quoted ? converted.name : fold(converted.name)) ||
801
+ source.qualifiers.length !== converted.qualifiers.length ||
802
+ source.qualifiers.some(
803
+ (qualifier, part) => qualifier !== converted.qualifiers[part],
804
+ )
805
+ );
806
+ });
807
+ // BigQuery splits dotted quoted names and folds CTE bindings. Refuse any
808
+ // conversion that could resolve a different relation before physical mapping.
809
+ if (
810
+ cteCollision ||
811
+ changedSource ||
812
+ postgresQuery.sources.length !== bigQuery.sources.length
813
+ ) {
814
+ throw new FirstPartyAnalyticsUnsupportedSqlError(
815
+ "PostgreSQL source bindings that change in BigQuery",
816
+ "First-party BigQuery query cannot preserve the PostgreSQL source bindings",
817
+ );
800
818
  }
801
-
802
- return result;
803
819
  }
804
820
 
805
- function maskSqlLiterals(sql: string): string {
806
- const chars = Array.from(sql);
807
- let inLiteral = false;
808
- for (let index = 0; index < chars.length; index++) {
809
- if (chars[index] !== "'") continue;
810
- if (!inLiteral) {
811
- inLiteral = true;
812
- chars[index] = " ";
813
- continue;
814
- }
815
- if (chars[index + 1] === "'") {
816
- chars[index] = " ";
817
- chars[index + 1] = " ";
818
- index++;
819
- continue;
820
- }
821
- chars[index] = " ";
822
- inLiteral = false;
823
- }
824
- for (let index = 0; index < chars.length; index++) {
825
- if (inLiteral) chars[index] = " ";
821
+ function maskSqlLiterals(
822
+ sql: string,
823
+ dialect: AgentSqlDialect = "postgres",
824
+ ): string {
825
+ const chars = sql.split("");
826
+ for (const token of lexAgentSql(sql, { dialect })) {
827
+ if (token.kind !== "string" && token.kind !== "quoted-identifier") continue;
828
+ chars.fill(" ", token.start, token.end);
826
829
  }
827
830
  return chars.join("");
828
831
  }
@@ -842,80 +845,50 @@ function rewriteWithMaskedSqlLiterals(
842
845
  const literals: string[] = [];
843
846
  let masked = "";
844
847
  let cursor = 0;
845
- while (cursor < sql.length) {
846
- const literalStart = sql.indexOf("'", cursor);
847
- if (literalStart === -1) {
848
- masked += sql.slice(cursor);
849
- break;
850
- }
851
- masked += sql.slice(cursor, literalStart);
852
- let literalEnd = literalStart + 1;
853
- while (literalEnd < sql.length) {
854
- if (sql[literalEnd] !== "'") {
855
- literalEnd++;
856
- continue;
857
- }
858
- if (sql[literalEnd + 1] === "'") {
859
- literalEnd += 2;
860
- continue;
861
- }
862
- literalEnd++;
863
- break;
864
- }
848
+ for (const token of lexAgentSql(sql, { dialect: "postgres" })) {
849
+ if (token.kind !== "string" && token.kind !== "quoted-identifier") continue;
850
+ masked += sql.slice(cursor, token.start);
865
851
  masked += `${SQL_LITERAL_PLACEHOLDER_PREFIX}${literals.length}_`;
866
- literals.push(sql.slice(literalStart, literalEnd));
867
- cursor = literalEnd;
852
+ literals.push(token.text);
853
+ cursor = token.end;
868
854
  }
869
- return rewrite(masked).replace(
855
+ return rewrite(masked + sql.slice(cursor)).replace(
870
856
  new RegExp(`${SQL_LITERAL_PLACEHOLDER_PREFIX}(\\d+)_`, "g"),
871
857
  (whole, index: string) => literals[Number(index)] ?? whole,
872
858
  );
873
859
  }
874
860
 
875
- function findMatchingSqlParen(sql: string, openIndex: number): number {
861
+ function findMatchingSqlParen(
862
+ sql: string,
863
+ openIndex: number,
864
+ dialect: AgentSqlDialect = "postgres",
865
+ ): number {
876
866
  let depth = 0;
877
- let inLiteral = false;
878
- for (let index = openIndex; index < sql.length; index++) {
879
- const char = sql[index];
880
- if (char === "'") {
881
- if (inLiteral && sql[index + 1] === "'") {
882
- index++;
883
- continue;
884
- }
885
- inLiteral = !inLiteral;
886
- continue;
887
- }
888
- if (inLiteral) continue;
889
- if (char === "(") depth++;
890
- if (char === ")") {
867
+ for (const token of lexAgentSql(sql, { dialect })) {
868
+ if (token.start < openIndex || token.kind !== "punctuation") continue;
869
+ if (token.text === "(") depth++;
870
+ if (token.text === ")") {
891
871
  depth--;
892
- if (depth === 0) return index;
872
+ if (depth === 0) return token.start;
893
873
  }
894
874
  }
895
875
  return -1;
896
876
  }
897
877
 
898
- function splitTopLevelSqlArgs(value: string): string[] {
878
+ function splitTopLevelSqlArgs(
879
+ value: string,
880
+ dialect: AgentSqlDialect = "postgres",
881
+ ): string[] {
899
882
  const args: string[] = [];
900
883
  let start = 0;
901
884
  let depth = 0;
902
- let inLiteral = false;
903
- for (let index = 0; index < value.length; index++) {
904
- const char = value[index];
905
- if (char === "'") {
906
- if (inLiteral && value[index + 1] === "'") {
907
- index++;
908
- continue;
909
- }
910
- inLiteral = !inLiteral;
911
- continue;
912
- }
913
- if (inLiteral) continue;
914
- if (char === "(") depth++;
915
- if (char === ")") depth--;
916
- if (char === "," && depth === 0) {
917
- args.push(value.slice(start, index).trim());
918
- start = index + 1;
885
+ for (const token of lexAgentSql(value, { dialect })) {
886
+ if (token.kind !== "punctuation") continue;
887
+ if (token.text === "(") depth++;
888
+ if (token.text === ")") depth--;
889
+ if (token.text === "," && depth === 0) {
890
+ args.push(value.slice(start, token.start).trim());
891
+ start = token.end;
919
892
  }
920
893
  }
921
894
  args.push(value.slice(start).trim());
@@ -960,19 +933,27 @@ function coerceDateComparisonOperands(sql: string): string {
960
933
  );
961
934
  let cursor = 0;
962
935
  let result = "";
936
+ const code = maskSqlLiterals(sql, "bigquery");
963
937
  let match = comparisonRe.exec(sql);
964
938
  while (match) {
939
+ if (code[match.index] === " ") {
940
+ match = comparisonRe.exec(sql);
941
+ continue;
942
+ }
965
943
  const operandStart = match.index + match[0].length;
966
944
  const formatMatch = /^FORMAT_DATE\s*\(/i.exec(sql.slice(operandStart));
967
945
  if (formatMatch) {
968
946
  const openIndex = operandStart + formatMatch[0].lastIndexOf("(");
969
- const closeIndex = findMatchingSqlParen(sql, openIndex);
947
+ const closeIndex = findMatchingSqlParen(sql, openIndex, "bigquery");
970
948
  if (closeIndex === -1) {
971
949
  throw new Error(
972
950
  "First-party BigQuery query has an unterminated FORMAT_DATE call",
973
951
  );
974
952
  }
975
- const args = splitTopLevelSqlArgs(sql.slice(openIndex + 1, closeIndex));
953
+ const args = splitTopLevelSqlArgs(
954
+ sql.slice(openIndex + 1, closeIndex),
955
+ "bigquery",
956
+ );
976
957
  if (args.length === 2 && /^'%Y-%m-%d'$/i.test(args[0] ?? "")) {
977
958
  result += sql.slice(cursor, operandStart) + (args[1] ?? "");
978
959
  cursor = closeIndex + 1;
@@ -988,12 +969,14 @@ function coerceDateComparisonOperands(sql: string): string {
988
969
  match = comparisonRe.exec(sql);
989
970
  }
990
971
  result += sql.slice(cursor);
972
+ const resultCode = maskSqlLiterals(result, "bigquery");
991
973
  return result.replace(
992
974
  new RegExp(
993
975
  `(\\b${qualifiedDateField}\\s*(?:<=|>=|<>|=|<|>)\\s*)'(\\d{4}-\\d{2}-\\d{2})'`,
994
976
  "gi",
995
977
  ),
996
- "$1DATE '$2'",
978
+ (whole, comparison: string, date: string, offset: number) =>
979
+ resultCode[offset] === " " ? whole : `${comparison}DATE '${date}'`,
997
980
  );
998
981
  }
999
982
 
@@ -1128,17 +1111,27 @@ const BIGQUERY_DATE_TRUNC_PARTS: Record<string, string> = {
1128
1111
  };
1129
1112
 
1130
1113
  function bigQueryJsonPath(key: string): string {
1131
- return `'$."${key.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"'`;
1114
+ const path = `$."${key.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1115
+ // Translation still carries PostgreSQL literals; the binder converts them
1116
+ // together after functions and casts have been rewritten.
1117
+ return `'${path.replace(/'/g, "''")}'`;
1132
1118
  }
1133
1119
 
1134
1120
  function translatePostgresJsonOperators(sql: string): string {
1135
- const jsonExtract = /\s*::\s*jsonb?\s*->>\s*'([^']*)'/gi;
1121
+ const jsonExtract = /\s*::\s*jsonb?\s*->>\s*('(?:[^']|'')*')/gi;
1136
1122
  let result = sql;
1137
1123
  let match = jsonExtract.exec(result);
1138
1124
  while (match) {
1125
+ const code = maskSqlLiterals(result);
1126
+ const castIndex = match.index + match[0].indexOf("::");
1127
+ if (code[castIndex] !== ":") {
1128
+ match = jsonExtract.exec(result);
1129
+ continue;
1130
+ }
1139
1131
  const { start, end } = postgresCastOperandBounds(result, match.index);
1140
1132
  const operand = result.slice(start, end).trim();
1141
- const replacement = `JSON_VALUE(${operand}, ${bigQueryJsonPath(match[1] ?? "")})`;
1133
+ const key = (match[1] ?? "").slice(1, -1).replace(/''/g, "'");
1134
+ const replacement = `JSON_VALUE(${operand}, ${bigQueryJsonPath(key)})`;
1142
1135
  result =
1143
1136
  result.slice(0, start) +
1144
1137
  replacement +
@@ -1150,7 +1143,7 @@ function translatePostgresJsonOperators(sql: string): string {
1150
1143
  }
1151
1144
 
1152
1145
  function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
1153
- let translated = translatePostgresJsonOperators(sql);
1146
+ let translated = translatePostgresJsonOperators(normalizePostgresSql(sql));
1154
1147
  translated = rewriteSqlFunctionCalls(translated, "coalesce", (args) => {
1155
1148
  const uniqueArgs: string[] = [];
1156
1149
  const seen = new Set<string>();
@@ -1162,10 +1155,13 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
1162
1155
  }
1163
1156
  return `COALESCE(${uniqueArgs.join(", ")})`;
1164
1157
  });
1158
+ const intervalCode = maskSqlLiterals(translated);
1165
1159
  translated = translated.replace(
1166
1160
  /\bINTERVAL\s*'(\d+)\s+(day|days|week|weeks|month|months)'/gi,
1167
- (_match, amount: string, unit: string) =>
1168
- `INTERVAL ${amount} ${unit.replace(/s$/i, "").toUpperCase()}`,
1161
+ (whole, amount: string, unit: string, offset: number) =>
1162
+ intervalCode[offset] === " "
1163
+ ? whole
1164
+ : `INTERVAL ${amount} ${unit.replace(/s$/i, "").toUpperCase()}`,
1169
1165
  );
1170
1166
  translated = rewriteSqlFunctionCalls(translated, "date_trunc", (args) => {
1171
1167
  const unit = /^'([a-z]+)'$/i.exec(args[0] ?? "")?.[1]?.toLowerCase();
@@ -1252,12 +1248,14 @@ function qualifyQuerySources(sql: string, table: BigQueryTableRef): string {
1252
1248
  analytics_event_daily_rollups: physical.dailyRollups,
1253
1249
  analytics_user_days: physical.userDays,
1254
1250
  };
1255
- const sourcePattern = FIRST_PARTY_QUERY_TABLES.join("|");
1256
- return sql.replace(
1257
- new RegExp(`\\b(from|join)\\s+(${sourcePattern})\\b`, "gi"),
1258
- (_match, keyword: string, logicalName: string) =>
1259
- `${keyword} \`${sourceMap[logicalName.toLowerCase()] ?? logicalName}\``,
1260
- );
1251
+ const query = readAgentSqlQuery(sql, { dialect: "bigquery" });
1252
+ return rewriteAgentSqlQuerySources(query, (source) => {
1253
+ const physicalName = sourceMap[source.name.toLowerCase()];
1254
+ if (source.cte || source.qualifiers.length > 0 || !physicalName) {
1255
+ return sql.slice(source.start, source.end);
1256
+ }
1257
+ return `\`${physicalName}\``;
1258
+ });
1261
1259
  }
1262
1260
 
1263
1261
  function addPartitionPrunedEventDeduplication(
@@ -1273,24 +1271,18 @@ function addPartitionPrunedEventDeduplication(
1273
1271
  " WHERE";
1274
1272
  let result = "";
1275
1273
  let cursor = 0;
1274
+ let searchStart = 0;
1275
+ const code = maskSqlLiterals(sql, "bigquery");
1276
1276
  while (cursor < sql.length) {
1277
- const sourceIndex = sql.indexOf(source, cursor);
1277
+ const sourceIndex = sql.indexOf(source, searchStart);
1278
1278
  if (sourceIndex === -1) return result + sql.slice(cursor);
1279
+ searchStart = sourceIndex + source.length;
1280
+ if (!code.startsWith("SELECT * FROM ", sourceIndex)) continue;
1279
1281
  const predicateStart = sourceIndex + source.length;
1280
1282
  let depth = 0;
1281
- let inLiteral = false;
1282
1283
  let predicateEnd = sql.length;
1283
1284
  for (let index = predicateStart; index < sql.length; index++) {
1284
- const char = sql[index];
1285
- if (char === "'") {
1286
- if (inLiteral && sql[index + 1] === "'") {
1287
- index++;
1288
- continue;
1289
- }
1290
- inLiteral = !inLiteral;
1291
- continue;
1292
- }
1293
- if (inLiteral) continue;
1285
+ const char = code[index];
1294
1286
  if (char === "(") {
1295
1287
  depth++;
1296
1288
  continue;
@@ -1303,7 +1295,7 @@ function addPartitionPrunedEventDeduplication(
1303
1295
  depth--;
1304
1296
  continue;
1305
1297
  }
1306
- if (depth === 0 && /^UNION\s+ALL\b/i.test(sql.slice(index))) {
1298
+ if (depth === 0 && /^UNION\s+ALL\b/i.test(code.slice(index))) {
1307
1299
  predicateEnd = index;
1308
1300
  break;
1309
1301
  }
@@ -1315,6 +1307,7 @@ function addPartitionPrunedEventDeduplication(
1315
1307
  " QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY received_at DESC) = 1" +
1316
1308
  (predicateEnd < sql.length ? " " : "");
1317
1309
  cursor = predicateEnd;
1310
+ searchStart = cursor;
1318
1311
  }
1319
1312
  return result;
1320
1313
  }
@@ -1331,13 +1324,21 @@ export function renderFirstPartyAnalyticsBigQuerySql(
1331
1324
  // The Postgres scope builder uses a text fallback for nullable event
1332
1325
  // dates. BigQuery's event_date is a DATE, and the fallback is unnecessary
1333
1326
  // because the sink normalizes it before insert.
1334
- const normalizedScopeSql = scopedSql.replace(
1327
+ const postgresSql = normalizePostgresSql(scopedSql);
1328
+ const scopeCode = maskSqlLiterals(postgresSql);
1329
+ const normalizedScopeSql = postgresSql.replace(
1335
1330
  /\(COALESCE\(NULLIF\(event_date, ''\), substr\(timestamp, 1, 10\)\) <= (\$\d+|\?)\)/g,
1336
- (_match, placeholder: string) => `(event_date <= ${placeholder})`,
1331
+ (whole, placeholder: string, offset: number) =>
1332
+ scopeCode[offset] === " " ? whole : `(event_date <= ${placeholder})`,
1337
1333
  );
1338
1334
  const translated =
1339
1335
  translateFirstPartyAnalyticsBigQuerySql(normalizedScopeSql);
1340
1336
  const bound = bindSqlArguments(translated, args);
1337
+ assertBigQuerySourceProvenance(translated, bound);
1338
+ validateAnalyticsSqlFunctions(
1339
+ readAgentSqlQuery(bound, { dialect: "bigquery" }),
1340
+ "bigquery",
1341
+ );
1341
1342
  return addPartitionPrunedEventDeduplication(
1342
1343
  coerceDateComparisonOperands(qualifyQuerySources(bound, table)),
1343
1344
  table,
@@ -2,7 +2,10 @@ import { createHash } from "crypto";
2
2
 
3
3
  import { getDbExec } from "@agent-native/core/db";
4
4
 
5
- import type { AnalyticsQueryResult } from "./first-party-analytics.js";
5
+ import type {
6
+ AnalyticsQueryResult,
7
+ AnalyticsScope,
8
+ } from "./first-party-analytics.js";
6
9
 
7
10
  interface L1Entry {
8
11
  result: AnalyticsQueryResult;
@@ -22,14 +25,27 @@ function inFlightKey(key: string, timeoutMs?: number): string {
22
25
 
23
26
  export interface FirstPartyCacheOptions {
24
27
  timeoutMs?: number;
28
+ deadlineAt?: number;
25
29
  }
26
30
 
31
+ /**
32
+ * The key names the actor and tenant explicitly, so a cached result is never
33
+ * served to another caller even when two scopes compile to the same SQL.
34
+ */
27
35
  export function firstPartyCacheKey(
28
36
  scopedSql: string,
29
37
  args: Array<string | null>,
38
+ scope: AnalyticsScope,
30
39
  ): string {
40
+ const caller = {
41
+ actor: scope.userEmail.trim().toLowerCase(),
42
+ orgId: scope.orgId ?? null,
43
+ credentialScope: scope.credentialScope ?? null,
44
+ };
31
45
  return createHash("sha256")
32
- .update(`${scopedSql}\n${JSON.stringify(args)}`)
46
+ .update(
47
+ `sql-policy-v2\n${JSON.stringify(caller)}\n${scopedSql}\n${JSON.stringify(args)}`,
48
+ )
33
49
  .digest("hex");
34
50
  }
35
51
 
@@ -120,6 +136,28 @@ function cacheIoTimeoutMs(deadlineAt: number): number {
120
136
  );
121
137
  }
122
138
 
139
+ // A caller can join a read that started after it did, so its own deadline,
140
+ // not the read's, bounds the wait.
141
+ function waitUntilDeadline(
142
+ promise: Promise<AnalyticsQueryResult>,
143
+ deadlineAt: number,
144
+ timeoutMs: number,
145
+ ): Promise<AnalyticsQueryResult> {
146
+ let timer: ReturnType<typeof setTimeout> | undefined;
147
+ const expired = new Promise<never>((_, reject) => {
148
+ timer = setTimeout(
149
+ () =>
150
+ reject(
151
+ new Error(
152
+ `First-party analytics query timed out after ${timeoutMs}ms`,
153
+ ),
154
+ ),
155
+ remainingTimeoutMs(deadlineAt),
156
+ );
157
+ });
158
+ return Promise.race([promise, expired]).finally(() => clearTimeout(timer));
159
+ }
160
+
123
161
  export async function withFirstPartyCache(
124
162
  key: string,
125
163
  sql: string,
@@ -130,11 +168,11 @@ export async function withFirstPartyCache(
130
168
  if (l1Hit) return l1Hit;
131
169
 
132
170
  const timeoutMs = Math.max(1, options.timeoutMs ?? CACHE_IO_TIMEOUT_MS);
133
- const deadlineAt = Date.now() + timeoutMs;
171
+ const deadlineAt = options.deadlineAt ?? Date.now() + timeoutMs;
134
172
 
135
173
  const requestKey = inFlightKey(key, options.timeoutMs);
136
174
  const existing = inFlight.get(requestKey);
137
- if (existing) return existing;
175
+ if (existing) return waitUntilDeadline(existing, deadlineAt, timeoutMs);
138
176
 
139
177
  const promise = (async () => {
140
178
  const l2Hit = await getL2(key, deadlineAt);