@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +5 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +27 -1
- package/corpus/templates/analytics/changelog/2026-10-02-dashboard-queries-preserve-recording-permissions-across-supp.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-05-agent-links-copied-from-a-dashboard-analysis-or-session-repl.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-05-bigquery-dashboard-filters-work-with-values-that-contain-apo.md +6 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +2 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +207 -206
- package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +42 -4
- package/corpus/templates/analytics/server/lib/first-party-analytics-sql-policy.ts +261 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +253 -528
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +1 -1
- package/corpus/templates/clips/changelog/2026-10-05-agent-links-copied-from-a-clip-are-shorter-so-claude-can-fet.md +6 -0
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -4
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +10 -5
- package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +6 -0
- package/corpus/templates/content/app/components/editor/CommentRow.tsx +49 -27
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +13 -8
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +23 -4
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -2
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +17 -0
- package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +156 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseTableGrid.tsx +10 -4
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +146 -78
- package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +1 -2
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +14 -8
- package/corpus/templates/content/app/components/editor/suggestions/model.ts +50 -5
- package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +107 -22
- package/corpus/templates/content/changelog/2026-10-02-collections-fit-phones-tablets-and-open-side-panels-view-tab.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-comment-and-suggestion-headers-keep-the-author-s-name-and-ti.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +1 -1
- package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +1 -1
- package/corpus/templates/content/changelog/2026-10-02-you-and-agents-can-now-suggest-edits-to-the-text-inside-tabl.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-agent-links-copied-from-a-document-are-shorter-so-claude-can.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-long-comments-now-scroll-inside-their-card-instead-of-jumpin.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-suggested-edits-that-rewrite-a-phrase-show-one-replacement.md +6 -0
- package/corpus/templates/content/server/lib/content-trash-purge.ts +10 -4
- package/corpus/templates/content/server/lib/suggested-edits.ts +44 -11
- package/corpus/templates/content/server/routes/api/_agent-native-background/content-trash-purge-worker.post.ts +6 -5
- package/corpus/templates/content/shared/suggestion-diff.ts +164 -49
- package/corpus/templates/content/shared/suggestion-formatting.ts +176 -44
- package/corpus/templates/content/shared/suggestion-rebase.ts +152 -19
- package/corpus/templates/design/changelog/2026-10-05-agent-links-copied-from-a-design-are-shorter-so-claude-can-f.md +6 -0
- package/corpus/templates/dispatch/changelog/2026-10-05-restored-default-apps-in-dispatch-and-kept-their-links-on-th.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-05-agent-links-copied-from-a-deck-are-shorter-so-claude-can-fet.md +6 -0
- 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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
)
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
738
|
+
let cursor = 0;
|
|
728
739
|
let result = "";
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
result +=
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
795
|
-
|
|
770
|
+
} else {
|
|
771
|
+
result += token.text;
|
|
796
772
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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(
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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(
|
|
867
|
-
cursor =
|
|
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(
|
|
861
|
+
function findMatchingSqlParen(
|
|
862
|
+
sql: string,
|
|
863
|
+
openIndex: number,
|
|
864
|
+
dialect: AgentSqlDialect = "postgres",
|
|
865
|
+
): number {
|
|
876
866
|
let depth = 0;
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
if (
|
|
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
|
|
872
|
+
if (depth === 0) return token.start;
|
|
893
873
|
}
|
|
894
874
|
}
|
|
895
875
|
return -1;
|
|
896
876
|
}
|
|
897
877
|
|
|
898
|
-
function splitTopLevelSqlArgs(
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
if (
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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*'([^']*)
|
|
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
|
|
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
|
-
(
|
|
1168
|
-
|
|
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
|
|
1256
|
-
return
|
|
1257
|
-
|
|
1258
|
-
(
|
|
1259
|
-
|
|
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,
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
(
|
|
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 {
|
|
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(
|
|
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);
|