@mastra/pg 1.22.0-alpha.0 → 1.22.0-alpha.4

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 (36) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/docs/SKILL.md +1 -1
  3. package/dist/docs/assets/SOURCE_MAP.json +1 -1
  4. package/dist/docs/references/docs-deployment-workers.md +2 -2
  5. package/dist/docs/references/docs-storage.md +2 -0
  6. package/dist/docs/references/integrations-databases-postgresql.md +26 -0
  7. package/dist/docs/references/reference-rag-vector-databases.md +4 -4
  8. package/dist/docs/references/reference-vectors-pg.md +2 -0
  9. package/dist/index.cjs +515 -133
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.js +515 -133
  12. package/dist/index.js.map +1 -1
  13. package/dist/storage/db/sanitize-json.d.ts +11 -0
  14. package/dist/storage/db/sanitize-json.d.ts.map +1 -0
  15. package/dist/storage/domains/background-tasks/index.d.ts +3 -1
  16. package/dist/storage/domains/background-tasks/index.d.ts.map +1 -1
  17. package/dist/storage/domains/experiments/index.d.ts.map +1 -1
  18. package/dist/storage/domains/observability/v-next/ddl.d.ts +17 -0
  19. package/dist/storage/domains/observability/v-next/ddl.d.ts.map +1 -1
  20. package/dist/storage/domains/observability/v-next/discovery.d.ts +14 -0
  21. package/dist/storage/domains/observability/v-next/discovery.d.ts.map +1 -1
  22. package/dist/storage/domains/observability/v-next/helpers.d.ts.map +1 -1
  23. package/dist/storage/domains/observability/v-next/index.d.ts.map +1 -1
  24. package/dist/storage/domains/observability/v-next/signal-schema.d.ts +5 -1
  25. package/dist/storage/domains/observability/v-next/signal-schema.d.ts.map +1 -1
  26. package/dist/storage/domains/observability/v-next/sql.d.ts +29 -4
  27. package/dist/storage/domains/observability/v-next/sql.d.ts.map +1 -1
  28. package/dist/storage/domains/observability/v-next/traces.d.ts.map +1 -1
  29. package/dist/storage/domains/observability/v-next/tracing.d.ts +4 -3
  30. package/dist/storage/domains/observability/v-next/tracing.d.ts.map +1 -1
  31. package/dist/storage/domains/workflows/index.d.ts +2 -10
  32. package/dist/storage/domains/workflows/index.d.ts.map +1 -1
  33. package/dist/storage/factory-storage.d.ts.map +1 -1
  34. package/dist/vector/index.d.ts +60 -5
  35. package/dist/vector/index.d.ts.map +1 -1
  36. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -541,11 +541,22 @@ function buildFilterQuery(filter, minScore, topK) {
541
541
  }
542
542
  //#endregion
543
543
  //#region src/vector/index.ts
544
- const MAX_UPSERT_ROWS_PER_STATEMENT = Math.floor(65535 / 3);
544
+ const DEFAULT_NAMESPACE = "default";
545
+ const MAX_UPSERT_ROWS_PER_STATEMENT = Math.floor(65535 / 4);
545
546
  var PgVector = class extends MastraVector {
546
547
  pool;
548
+ /**
549
+ * Cache for the public `getIndexInfo()`. Holds the in-flight promise rather than the
550
+ * resolved value so concurrent callers on a cold cache share a single round trip.
551
+ */
547
552
  describeIndexCache = /* @__PURE__ */ new Map();
553
+ /**
554
+ * Cache for the catalog-only index metadata used by the internal hot paths
555
+ * (cache warmup, query, upsert, updateVector, setupIndex). Also memoizes the promise.
556
+ */
557
+ indexMetadataCache = /* @__PURE__ */ new Map();
548
558
  createdIndexes = /* @__PURE__ */ new Map();
559
+ namespaceReadyIndexes = /* @__PURE__ */ new Set();
549
560
  indexVectorTypes = /* @__PURE__ */ new Map();
550
561
  mutexesByName = /* @__PURE__ */ new Map();
551
562
  schema;
@@ -601,7 +612,7 @@ var PgVector = class extends MastraVector {
601
612
  try {
602
613
  const existingIndexes = await this.listIndexes();
603
614
  await Promise.all(existingIndexes.map(async (indexName) => {
604
- const info = await this.getIndexInfo({ indexName });
615
+ const info = await this.getIndexMetadata({ indexName });
605
616
  const key = await this.getIndexCacheKey({
606
617
  indexName,
607
618
  metric: info.metric,
@@ -774,20 +785,76 @@ var PgVector = class extends MastraVector {
774
785
  const quotedVectorName = `"${parsedIndexName}_vector_idx"`;
775
786
  return {
776
787
  tableName: quotedSchemaName ? `${quotedSchemaName}.${quotedIndexName}` : quotedIndexName,
777
- vectorIndexName: quotedVectorName
788
+ vectorIndexName: quotedVectorName,
789
+ parsedIndexName
778
790
  };
779
791
  }
780
792
  getSchemaName() {
781
793
  return this.schema ? `"${parseSqlIdentifier(this.schema, "schema name")}"` : void 0;
782
794
  }
795
+ async ensureNamespaceSchema(indexName, client) {
796
+ const { tableName, parsedIndexName } = this.getTableName(indexName);
797
+ const schemaName = this.schema ? parseSqlIdentifier(this.schema, "schema name") : "public";
798
+ if ((await client.query(`SELECT 1
799
+ FROM information_schema.columns
800
+ WHERE table_schema = $1 AND table_name = $2 AND column_name = 'vector_id'`, [schemaName, parsedIndexName])).rowCount === 0) return;
801
+ await client.query(`ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS namespace VARCHAR(255) NOT NULL DEFAULT '${DEFAULT_NAMESPACE}'`);
802
+ const legacyConstraints = await client.query(`SELECT c.conname
803
+ FROM pg_constraint c
804
+ JOIN pg_class t ON t.oid = c.conrelid
805
+ JOIN pg_namespace n ON n.oid = t.relnamespace
806
+ WHERE n.nspname = $1
807
+ AND t.relname = $2
808
+ AND c.contype = 'u'
809
+ AND pg_get_constraintdef(c.oid) = 'UNIQUE (vector_id)'`, [schemaName, parsedIndexName]);
810
+ for (const { conname } of legacyConstraints.rows) {
811
+ const parsedConstraintName = parseSqlIdentifier(conname, "constraint name");
812
+ await client.query(`ALTER TABLE ${tableName} DROP CONSTRAINT "${parsedConstraintName}"`);
813
+ }
814
+ const namespaceIndexName = parseSqlIdentifier(`${parsedIndexName}_namespace_vector_id_idx`, "index name");
815
+ await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS "${namespaceIndexName}" ON ${tableName} (namespace, vector_id)`);
816
+ }
783
817
  transformFilter(filter) {
784
818
  return new PGFilterTranslator().translate(filter);
785
819
  }
820
+ /**
821
+ * Cached variant of {@link describeIndex}, including the row count.
822
+ *
823
+ * Internal code paths do not use this - they use the catalog-only
824
+ * {@link getIndexMetadata}, which never scans the table.
825
+ */
786
826
  async getIndexInfo({ indexName }) {
787
- if (!this.describeIndexCache.has(indexName)) this.describeIndexCache.set(indexName, await this.describeIndex({ indexName }));
788
- return this.describeIndexCache.get(indexName);
827
+ return this.memoize(this.describeIndexCache, indexName, () => this.describeIndex({ indexName }));
828
+ }
829
+ /**
830
+ * Cached index metadata read from the Postgres catalog only.
831
+ *
832
+ * This is what every internal caller needs: none of them read `count`, and paying for
833
+ * `SELECT COUNT(*)` on a large index costs a full heap scan per call.
834
+ */
835
+ getIndexMetadata({ indexName }) {
836
+ return this.memoize(this.indexMetadataCache, indexName, () => this.describeIndexMetadata({ indexName }));
837
+ }
838
+ /**
839
+ * Stores the in-flight promise in `cache` so concurrent callers on a cold cache share one
840
+ * round trip, and drops the entry if it rejects so a transient failure is not cached forever.
841
+ */
842
+ memoize(cache, key, load) {
843
+ const cached = cache.get(key);
844
+ if (cached) return cached;
845
+ const pending = load();
846
+ cache.set(key, pending);
847
+ pending.catch(() => {
848
+ if (cache.get(key) === pending) cache.delete(key);
849
+ });
850
+ return pending;
851
+ }
852
+ /** Drops every cached view of an index, e.g. after its index definition or table changed. */
853
+ invalidateIndexCaches(indexName) {
854
+ this.describeIndexCache.delete(indexName);
855
+ this.indexMetadataCache.delete(indexName);
789
856
  }
790
- async query({ indexName, queryVector, topK = 10, filter, includeVector = false, minScore = -1, ef, probes }) {
857
+ async query({ indexName, queryVector, topK = 10, filter, includeVector = false, minScore = -1, ef, probes, namespace = DEFAULT_NAMESPACE }) {
791
858
  try {
792
859
  validateTopK("PG", topK);
793
860
  if (queryVector !== void 0) {
@@ -808,16 +875,23 @@ var PgVector = class extends MastraVector {
808
875
  try {
809
876
  const { sql: filterQuery, values: filterValues } = buildDeleteFilterQuery(this.transformFilter(filter));
810
877
  const { tableName } = this.getTableName(indexName);
878
+ const filterClause = filterQuery.trim().replace(/^WHERE\s+/i, "");
879
+ const namespaceParam = filterValues.length + 1;
811
880
  const query = `
812
881
  SELECT
813
882
  vector_id as id,
814
883
  metadata
815
884
  ${includeVector ? ", embedding" : ""}
816
885
  FROM ${tableName}
817
- ${filterQuery}
886
+ WHERE namespace = $${namespaceParam}
887
+ ${filterClause ? `AND (${filterClause})` : ""}
818
888
  ORDER BY vector_id
819
- LIMIT $${filterValues.length + 1}`;
820
- return (await client.query(query, [...filterValues, topK])).rows.map(({ id, metadata, embedding }) => ({
889
+ LIMIT $${namespaceParam + 1}`;
890
+ return (await client.query(query, [
891
+ ...filterValues,
892
+ namespace,
893
+ topK
894
+ ])).rows.map(({ id, metadata, embedding }) => ({
821
895
  id,
822
896
  score: 0,
823
897
  metadata,
@@ -841,7 +915,7 @@ var PgVector = class extends MastraVector {
841
915
  await this.ensureSearchPath(client);
842
916
  await client.query("BEGIN");
843
917
  const { sql: filterQuery, values: filterValues } = buildFilterQuery(this.transformFilter(filter), minScore, topK);
844
- const indexInfo = await this.getIndexInfo({ indexName });
918
+ const indexInfo = await this.getIndexMetadata({ indexName });
845
919
  const metric = indexInfo.metric ?? "cosine";
846
920
  const ops = this.getVectorOps(indexInfo.vectorType, metric);
847
921
  const vectorStr = ops.formatVector(queryVector, indexInfo.dimension);
@@ -849,14 +923,18 @@ var PgVector = class extends MastraVector {
849
923
  const calculatedEf = ef ?? Math.max(topK, (indexInfo?.config?.m ?? 16) * topK);
850
924
  const searchEf = Math.min(1e3, Math.max(1, calculatedEf));
851
925
  await client.query(`SET LOCAL hnsw.ef_search = ${searchEf}`);
926
+ await client.query(`SET LOCAL hnsw.iterative_scan = strict_order`);
852
927
  }
853
928
  if (indexInfo.type === "ivfflat" && probes) await client.query(`SET LOCAL ivfflat.probes = ${probes}`);
854
929
  const { tableName } = this.getTableName(indexName);
855
930
  const qualifiedVectorType = this.getVectorTypeName(indexInfo.vectorType, indexInfo.dimension);
856
931
  const distanceExpr = `embedding ${ops.distanceOperator} '${vectorStr}'::${qualifiedVectorType}`;
857
932
  const scoreExpr = ops.scoreExpr(distanceExpr);
858
- const hasFilter = filterQuery.trim().length > 0;
859
- const query = indexInfo.type === "hnsw" && !hasFilter && minScore <= 0 ? `
933
+ const filterClause = filterQuery.trim().replace(/^WHERE\s+/i, "");
934
+ const hasFilter = filterClause.length > 0;
935
+ const useIndexedOrder = indexInfo.type === "hnsw" && !hasFilter && minScore <= 0;
936
+ const namespaceParam = filterValues.length + 1;
937
+ const query = useIndexedOrder ? `
860
938
  WITH vector_scores AS (
861
939
  SELECT
862
940
  vector_id as id,
@@ -864,6 +942,7 @@ var PgVector = class extends MastraVector {
864
942
  metadata
865
943
  ${includeVector ? ", embedding" : ""}
866
944
  FROM ${tableName}
945
+ WHERE namespace = $${namespaceParam}
867
946
  ORDER BY ${distanceExpr}
868
947
  LIMIT $2
869
948
  )
@@ -878,14 +957,15 @@ var PgVector = class extends MastraVector {
878
957
  metadata
879
958
  ${includeVector ? ", embedding" : ""}
880
959
  FROM ${tableName}
881
- ${filterQuery}
960
+ WHERE namespace = $${namespaceParam}
961
+ ${filterClause ? `AND (${filterClause})` : ""}
882
962
  )
883
963
  SELECT *
884
964
  FROM vector_scores
885
965
  WHERE score > $1
886
966
  ORDER BY score DESC
887
967
  LIMIT $2`;
888
- const result = await client.query(query, filterValues);
968
+ const result = await client.query(query, [...filterValues, namespace]);
889
969
  await client.query("COMMIT");
890
970
  return result.rows.map(({ id, score, metadata, embedding }) => ({
891
971
  id,
@@ -907,7 +987,7 @@ var PgVector = class extends MastraVector {
907
987
  client.release();
908
988
  }
909
989
  }
910
- async upsert({ indexName, vectors, metadata, ids, deleteFilter }) {
990
+ async upsert({ indexName, vectors, metadata, ids, deleteFilter, namespace = DEFAULT_NAMESPACE }) {
911
991
  validateUpsertInput("PG", vectors, metadata, ids);
912
992
  const { tableName } = this.getTableName(indexName);
913
993
  const client = await this.pool.connect();
@@ -922,8 +1002,8 @@ var PgVector = class extends MastraVector {
922
1002
  const { sql: filterQuery, values: filterValues } = buildDeleteFilterQuery(this.transformFilter(deleteFilter));
923
1003
  const whereClause = filterQuery.trim().replace(/^WHERE\s+/i, "");
924
1004
  if (whereClause) {
925
- const deleteQuery = `DELETE FROM ${tableName} WHERE ${whereClause}`;
926
- const result = await client.query(deleteQuery, filterValues);
1005
+ const deleteQuery = `DELETE FROM ${tableName} WHERE namespace = $${filterValues.length + 1} AND (${whereClause})`;
1006
+ const result = await client.query(deleteQuery, [...filterValues, namespace]);
927
1007
  this.logger?.debug(`Deleted ${result.rowCount || 0} vectors before upsert`, {
928
1008
  indexName,
929
1009
  deletedCount: result.rowCount || 0
@@ -931,15 +1011,15 @@ var PgVector = class extends MastraVector {
931
1011
  }
932
1012
  }
933
1013
  const vectorIds = ids || vectors.map(() => crypto.randomUUID());
934
- const indexInfo = await this.getIndexInfo({ indexName });
1014
+ const indexInfo = await this.getIndexMetadata({ indexName });
935
1015
  const qualifiedVectorType = this.getVectorTypeName(indexInfo.vectorType, indexInfo.dimension);
936
1016
  const ops = this.getVectorOps(indexInfo.vectorType, indexInfo.metric ?? "cosine");
937
1017
  if (new Set(vectorIds).size !== vectorIds.length) for (let i = 0; i < vectors.length; i++) {
938
1018
  const vectorStr = ops.formatVector(vectors[i], indexInfo.dimension);
939
1019
  const query = `
940
- INSERT INTO ${tableName} (vector_id, embedding, metadata)
941
- VALUES ($1, $2::${qualifiedVectorType}, $3::jsonb)
942
- ON CONFLICT (vector_id)
1020
+ INSERT INTO ${tableName} (vector_id, embedding, metadata, namespace)
1021
+ VALUES ($1, $2::${qualifiedVectorType}, $3::jsonb, $4)
1022
+ ON CONFLICT (namespace, vector_id)
943
1023
  DO UPDATE SET
944
1024
  embedding = $2::${qualifiedVectorType},
945
1025
  metadata = $3::jsonb
@@ -947,7 +1027,8 @@ var PgVector = class extends MastraVector {
947
1027
  await client.query(query, [
948
1028
  vectorIds[i],
949
1029
  vectorStr,
950
- JSON.stringify(metadata?.[i] || {})
1030
+ JSON.stringify(metadata?.[i] || {}),
1031
+ namespace
951
1032
  ]);
952
1033
  }
953
1034
  else for (let start = 0; start < vectors.length; start += MAX_UPSERT_ROWS_PER_STATEMENT) {
@@ -956,13 +1037,13 @@ var PgVector = class extends MastraVector {
956
1037
  const values = [];
957
1038
  for (let i = start; i < end; i++) {
958
1039
  const base = values.length;
959
- rows.push(`($${base + 1}, $${base + 2}::${qualifiedVectorType}, $${base + 3}::jsonb)`);
960
- values.push(vectorIds[i], ops.formatVector(vectors[i], indexInfo.dimension), JSON.stringify(metadata?.[i] || {}));
1040
+ rows.push(`($${base + 1}, $${base + 2}::${qualifiedVectorType}, $${base + 3}::jsonb, $${base + 4})`);
1041
+ values.push(vectorIds[i], ops.formatVector(vectors[i], indexInfo.dimension), JSON.stringify(metadata?.[i] || {}), namespace);
961
1042
  }
962
1043
  const query = `
963
- INSERT INTO ${tableName} (vector_id, embedding, metadata)
1044
+ INSERT INTO ${tableName} (vector_id, embedding, metadata, namespace)
964
1045
  VALUES ${rows.join(", ")}
965
- ON CONFLICT (vector_id)
1046
+ ON CONFLICT (namespace, vector_id)
966
1047
  DO UPDATE SET
967
1048
  embedding = EXCLUDED.embedding,
968
1049
  metadata = EXCLUDED.metadata
@@ -1082,9 +1163,9 @@ var PgVector = class extends MastraVector {
1082
1163
  vectorType,
1083
1164
  metadataIndexes
1084
1165
  });
1085
- if (this.cachedIndexExists(indexName, indexCacheKey)) return;
1166
+ if (this.cachedIndexExists(indexName, indexCacheKey) && this.namespaceReadyIndexes.has(indexName)) return;
1086
1167
  await this.getMutexByName(`create-${indexName}`).runExclusive(async () => {
1087
- if (this.cachedIndexExists(indexName, indexCacheKey)) return;
1168
+ if (this.cachedIndexExists(indexName, indexCacheKey) && this.namespaceReadyIndexes.has(indexName)) return;
1088
1169
  const client = await this.pool.connect();
1089
1170
  try {
1090
1171
  await this.setupSchema(client);
@@ -1128,12 +1209,15 @@ var PgVector = class extends MastraVector {
1128
1209
  await client.query(`
1129
1210
  CREATE TABLE IF NOT EXISTS ${tableName} (
1130
1211
  id SERIAL PRIMARY KEY,
1131
- vector_id TEXT UNIQUE NOT NULL,
1212
+ vector_id TEXT NOT NULL,
1132
1213
  embedding ${qualifiedVectorType}(${dimension}),
1133
- metadata JSONB DEFAULT '{}'::jsonb
1214
+ metadata JSONB DEFAULT '{}'::jsonb,
1215
+ namespace VARCHAR(255) NOT NULL DEFAULT '${DEFAULT_NAMESPACE}'
1134
1216
  );
1135
1217
  `);
1218
+ await this.ensureNamespaceSchema(indexName, client);
1136
1219
  this.createdIndexes.set(indexName, indexCacheKey);
1220
+ this.namespaceReadyIndexes.add(indexName);
1137
1221
  this.indexVectorTypes.set(indexName, vectorType);
1138
1222
  if (buildIndex) await this.setupIndex({
1139
1223
  indexName,
@@ -1144,6 +1228,7 @@ var PgVector = class extends MastraVector {
1144
1228
  if (metadataIndexes?.length) await this.createMetadataIndexes(tableName, indexName, metadataIndexes);
1145
1229
  } catch (error) {
1146
1230
  this.createdIndexes.delete(indexName);
1231
+ this.namespaceReadyIndexes.delete(indexName);
1147
1232
  this.indexVectorTypes.delete(indexName);
1148
1233
  throw error;
1149
1234
  } finally {
@@ -1214,7 +1299,7 @@ var PgVector = class extends MastraVector {
1214
1299
  let existingIndexInfo = null;
1215
1300
  let dimension = 0;
1216
1301
  try {
1217
- existingIndexInfo = await this.getIndexInfo({ indexName });
1302
+ existingIndexInfo = await this.getIndexMetadata({ indexName });
1218
1303
  dimension = existingIndexInfo.dimension;
1219
1304
  if (isConfigEmpty && existingIndexInfo.metric === metric) if (existingIndexInfo.type === "flat") this.logger?.debug(`No index exists for ${vectorIndexName}, will create default ivfflat index`);
1220
1305
  else {
@@ -1249,12 +1334,12 @@ var PgVector = class extends MastraVector {
1249
1334
  }
1250
1335
  this.logger?.info(`Index ${vectorIndexName} configuration changed, rebuilding index`);
1251
1336
  await client.query(`DROP INDEX IF EXISTS ${vectorIndexName}`);
1252
- this.describeIndexCache.delete(indexName);
1337
+ this.invalidateIndexCaches(indexName);
1253
1338
  } catch {
1254
1339
  this.logger?.debug(`Index ${indexName} doesn't exist yet, will create it`);
1255
1340
  }
1256
1341
  if (indexType === "flat") {
1257
- this.describeIndexCache.delete(indexName);
1342
+ this.invalidateIndexCaches(indexName);
1258
1343
  return;
1259
1344
  }
1260
1345
  await this.ensureSearchPath(client);
@@ -1404,6 +1489,19 @@ var PgVector = class extends MastraVector {
1404
1489
  * @returns A promise that resolves to the index statistics including dimension, count and metric
1405
1490
  */
1406
1491
  async describeIndex({ indexName }) {
1492
+ const metadata = await this.describeIndexMetadata({ indexName });
1493
+ const count = await this.countIndexRows({ indexName });
1494
+ return {
1495
+ ...metadata,
1496
+ count
1497
+ };
1498
+ }
1499
+ /**
1500
+ * Reads the index metadata that lives in the Postgres catalog. Unlike
1501
+ * {@link describeIndex} it issues no `COUNT(*)`, so its cost does not grow with the
1502
+ * number of rows in the table.
1503
+ */
1504
+ async describeIndexMetadata({ indexName }) {
1407
1505
  const client = await this.pool.connect();
1408
1506
  try {
1409
1507
  const { tableName } = this.getTableName(indexName);
@@ -1423,10 +1521,6 @@ var PgVector = class extends MastraVector {
1423
1521
  FROM pg_attribute
1424
1522
  WHERE attrelid = $1::regclass
1425
1523
  AND attname = 'embedding';
1426
- `;
1427
- const countQuery = `
1428
- SELECT COUNT(*) as count
1429
- FROM ${tableName};
1430
1524
  `;
1431
1525
  const indexQuery = `
1432
1526
  SELECT
@@ -1442,7 +1536,6 @@ var PgVector = class extends MastraVector {
1442
1536
  AND n.nspname = $2;
1443
1537
  `;
1444
1538
  const dimResult = await client.query(dimensionQuery, [tableName]);
1445
- const countResult = await client.query(countQuery);
1446
1539
  const { index_method, index_def, operator_class } = (await client.query(indexQuery, [`${indexName}_vector_idx`, this.schema || "public"])).rows[0] || {
1447
1540
  index_method: "flat",
1448
1541
  index_def: "",
@@ -1461,7 +1554,6 @@ var PgVector = class extends MastraVector {
1461
1554
  }
1462
1555
  return {
1463
1556
  dimension: dimResult.rows[0].dimension,
1464
- count: parseInt(countResult.rows[0].count),
1465
1557
  metric,
1466
1558
  type: index_method,
1467
1559
  vectorType,
@@ -1481,14 +1573,41 @@ var PgVector = class extends MastraVector {
1481
1573
  client.release();
1482
1574
  }
1483
1575
  }
1576
+ /**
1577
+ * Exact row count for an index. This is a full scan of the table, so it is only issued
1578
+ * for the public {@link describeIndex}, never from an internal code path.
1579
+ */
1580
+ async countIndexRows({ indexName }) {
1581
+ const client = await this.pool.connect();
1582
+ try {
1583
+ const { tableName } = this.getTableName(indexName);
1584
+ const countResult = await client.query(`
1585
+ SELECT COUNT(*) as count
1586
+ FROM ${tableName};
1587
+ `);
1588
+ return parseInt(countResult.rows[0].count);
1589
+ } catch (e) {
1590
+ const mastraError = new MastraError({
1591
+ id: createVectorErrorId("PG", "DESCRIBE_INDEX", "FAILED"),
1592
+ domain: ErrorDomain.MASTRA_VECTOR,
1593
+ category: ErrorCategory.THIRD_PARTY,
1594
+ details: { indexName }
1595
+ }, e);
1596
+ this.logger?.trackException(mastraError);
1597
+ throw mastraError;
1598
+ } finally {
1599
+ client.release();
1600
+ }
1601
+ }
1484
1602
  async deleteIndex({ indexName }) {
1485
1603
  const client = await this.pool.connect();
1486
1604
  try {
1487
1605
  const { tableName } = this.getTableName(indexName);
1488
1606
  await client.query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
1489
1607
  this.createdIndexes.delete(indexName);
1608
+ this.namespaceReadyIndexes.delete(indexName);
1490
1609
  this.indexVectorTypes.delete(indexName);
1491
- this.describeIndexCache.delete(indexName);
1610
+ this.invalidateIndexCaches(indexName);
1492
1611
  } catch (error) {
1493
1612
  await client.query("ROLLBACK");
1494
1613
  const mastraError = new MastraError({
@@ -1538,7 +1657,7 @@ var PgVector = class extends MastraVector {
1538
1657
  * @returns A promise that resolves when the update is complete.
1539
1658
  * @throws Will throw an error if no updates are provided or if the update operation fails.
1540
1659
  */
1541
- async updateVector({ indexName, id, filter, update }) {
1660
+ async updateVector({ indexName, id, filter, update, namespace = DEFAULT_NAMESPACE }) {
1542
1661
  let client;
1543
1662
  try {
1544
1663
  if (!update.vector && !update.metadata) throw new Error("No updates provided");
@@ -1559,7 +1678,7 @@ var PgVector = class extends MastraVector {
1559
1678
  client = await this.pool.connect();
1560
1679
  await this.ensureSearchPath(client);
1561
1680
  const { tableName } = this.getTableName(indexName);
1562
- const indexInfo = await this.getIndexInfo({ indexName });
1681
+ const indexInfo = await this.getIndexMetadata({ indexName });
1563
1682
  const qualifiedVectorType = this.getVectorTypeName(indexInfo.vectorType, indexInfo.dimension);
1564
1683
  const ops = this.getVectorOps(indexInfo.vectorType, indexInfo.metric ?? "cosine");
1565
1684
  let updateParts = [];
@@ -1578,8 +1697,11 @@ var PgVector = class extends MastraVector {
1578
1697
  if (updateParts.length === 0) return;
1579
1698
  let whereClause;
1580
1699
  let whereValues;
1700
+ const namespaceIndex = valueIndex;
1701
+ values.push(namespace);
1702
+ valueIndex++;
1581
1703
  if (id) {
1582
- whereClause = `vector_id = $${valueIndex}`;
1704
+ whereClause = `namespace = $${namespaceIndex} AND vector_id = $${valueIndex}`;
1583
1705
  whereValues = [id];
1584
1706
  } else {
1585
1707
  if (!filter || Object.keys(filter).length === 0) throw new MastraError({
@@ -1604,6 +1726,7 @@ var PgVector = class extends MastraVector {
1604
1726
  whereClause = whereClause.replace(/\$(\d+)/g, (match, num) => {
1605
1727
  return `$${parseInt(num) + valueIndex - 1}`;
1606
1728
  });
1729
+ whereClause = `namespace = $${namespaceIndex} AND (${whereClause})`;
1607
1730
  whereValues = filterValues;
1608
1731
  }
1609
1732
  const query = `
@@ -1643,16 +1766,16 @@ var PgVector = class extends MastraVector {
1643
1766
  * @returns A promise that resolves when the deletion is complete.
1644
1767
  * @throws Will throw an error if the deletion operation fails.
1645
1768
  */
1646
- async deleteVector({ indexName, id }) {
1769
+ async deleteVector({ indexName, id, namespace = DEFAULT_NAMESPACE }) {
1647
1770
  let client;
1648
1771
  try {
1649
1772
  client = await this.pool.connect();
1650
1773
  const { tableName } = this.getTableName(indexName);
1651
1774
  const query = `
1652
1775
  DELETE FROM ${tableName}
1653
- WHERE vector_id = $1
1776
+ WHERE vector_id = $1 AND namespace = $2
1654
1777
  `;
1655
- await client.query(query, [id]);
1778
+ await client.query(query, [id, namespace]);
1656
1779
  } catch (error) {
1657
1780
  const mastraError = new MastraError({
1658
1781
  id: createVectorErrorId("PG", "DELETE_VECTOR", "FAILED"),
@@ -1676,14 +1799,15 @@ var PgVector = class extends MastraVector {
1676
1799
  * @returns A promise that resolves when the deletion is complete.
1677
1800
  * @throws Will throw an error if the deletion operation fails.
1678
1801
  */
1679
- async deleteVectors({ indexName, filter, ids }) {
1802
+ async deleteVectors({ indexName, filter, ids, namespace }) {
1680
1803
  let client;
1804
+ const effectiveNamespace = namespace ?? DEFAULT_NAMESPACE;
1681
1805
  try {
1682
1806
  client = await this.pool.connect();
1683
1807
  const { tableName } = this.getTableName(indexName);
1684
- if (!filter && !ids) throw new MastraError({
1808
+ if (!filter && !ids && namespace === void 0) throw new MastraError({
1685
1809
  id: createVectorErrorId("PG", "DELETE_VECTORS", "NO_TARGET"),
1686
- text: "Either filter or ids must be provided",
1810
+ text: "Either filter or ids must be provided, unless an explicit namespace is used",
1687
1811
  domain: ErrorDomain.MASTRA_VECTOR,
1688
1812
  category: ErrorCategory.USER,
1689
1813
  details: { indexName }
@@ -1705,9 +1829,9 @@ var PgVector = class extends MastraVector {
1705
1829
  category: ErrorCategory.USER,
1706
1830
  details: { indexName }
1707
1831
  });
1708
- query = `DELETE FROM ${tableName} WHERE vector_id IN (${ids.map((_, i) => `$${i + 1}`).join(", ")})`;
1709
- values = ids;
1710
- } else {
1832
+ query = `DELETE FROM ${tableName} WHERE vector_id IN (${ids.map((_, i) => `$${i + 1}`).join(", ")}) AND namespace = $${ids.length + 1}`;
1833
+ values = [...ids, effectiveNamespace];
1834
+ } else if (filter) {
1711
1835
  if (!filter || Object.keys(filter).length === 0) throw new MastraError({
1712
1836
  id: createVectorErrorId("PG", "DELETE_VECTORS", "EMPTY_FILTER"),
1713
1837
  text: "Cannot delete with empty filter. Use deleteIndex to delete all vectors.",
@@ -1727,8 +1851,11 @@ var PgVector = class extends MastraVector {
1727
1851
  filter: JSON.stringify(filter)
1728
1852
  }
1729
1853
  });
1730
- query = `DELETE FROM ${tableName} WHERE ${whereClause}`;
1731
- values = filterValues;
1854
+ query = `DELETE FROM ${tableName} WHERE namespace = $${filterValues.length + 1} AND (${whereClause})`;
1855
+ values = [...filterValues, effectiveNamespace];
1856
+ } else {
1857
+ query = `DELETE FROM ${tableName} WHERE namespace = $1`;
1858
+ values = [effectiveNamespace];
1732
1859
  }
1733
1860
  const result = await client.query(query, values);
1734
1861
  this.logger?.info(`Deleted ${result.rowCount || 0} vectors from ${indexName}`, {
@@ -4894,7 +5021,7 @@ var BackgroundTasksPG = class BackgroundTasksPG extends BackgroundTasksStorage {
4894
5021
  }
4895
5022
  });
4896
5023
  }
4897
- async updateTask(taskId, update) {
5024
+ async updateTask(taskId, update, options) {
4898
5025
  const setClauses = [];
4899
5026
  const params = [];
4900
5027
  let paramIdx = 1;
@@ -4936,10 +5063,15 @@ var BackgroundTasksPG = class BackgroundTasksPG extends BackgroundTasksStorage {
4936
5063
  const val = update.completedAt?.toISOString() ?? null;
4937
5064
  params.push(val, val);
4938
5065
  }
4939
- if (setClauses.length === 0) return;
5066
+ if (setClauses.length === 0) return false;
4940
5067
  const table = getTableName$4(getSchemaName$4(this.#schema));
4941
5068
  params.push(taskId);
4942
- await this.#db.client.none(`UPDATE ${table} SET ${setClauses.join(", ")} WHERE "id" = $${paramIdx}`, params);
5069
+ let where = `"id" = $${paramIdx++}`;
5070
+ if (options?.expectedStatus) {
5071
+ where += ` AND "status" = $${paramIdx}`;
5072
+ params.push(options.expectedStatus);
5073
+ }
5074
+ return ((await this.#db.client.query(`UPDATE ${table} SET ${setClauses.join(", ")} WHERE ${where}`, params)).rowCount ?? 0) > 0;
4943
5075
  }
4944
5076
  async getTask(taskId) {
4945
5077
  const table = getTableName$4(getSchemaName$4(this.#schema));
@@ -6584,6 +6716,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
6584
6716
  "tags",
6585
6717
  "comment",
6586
6718
  "toolMockReport",
6719
+ "metadata",
6587
6720
  "organizationId",
6588
6721
  "projectId",
6589
6722
  "attempt"
@@ -6780,6 +6913,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
6780
6913
  input: safelyParseJSON(row.input),
6781
6914
  output: row.output ? safelyParseJSON(row.output) : null,
6782
6915
  groundTruth: row.groundTruth ? safelyParseJSON(row.groundTruth) : null,
6916
+ metadata: row.metadata ? safelyParseJSON(row.metadata) : null,
6783
6917
  error: row.error ? safelyParseJSON(row.error) : null,
6784
6918
  startedAt: ensureDate(row.startedAtZ || row.startedAt),
6785
6919
  completedAt: ensureDate(row.completedAtZ || row.completedAt),
@@ -7088,6 +7222,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7088
7222
  input: input.input,
7089
7223
  output: input.output ?? null,
7090
7224
  groundTruth: input.groundTruth ?? null,
7225
+ metadata: input.metadata ?? null,
7091
7226
  error: input.error ?? null,
7092
7227
  startedAt: input.startedAt.toISOString(),
7093
7228
  completedAt: input.completedAt.toISOString(),
@@ -7110,6 +7245,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7110
7245
  input: input.input,
7111
7246
  output: input.output ?? null,
7112
7247
  groundTruth: input.groundTruth ?? null,
7248
+ metadata: input.metadata ?? null,
7113
7249
  error: input.error ?? null,
7114
7250
  startedAt: input.startedAt,
7115
7251
  completedAt: input.completedAt,
@@ -7146,9 +7282,9 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7146
7282
  const id = crypto.randomUUID();
7147
7283
  return t.one(`INSERT INTO ${tableName} (
7148
7284
  "id", "experimentId", "itemId", "itemDatasetVersion", "organizationId", "projectId",
7149
- "input", "output", "groundTruth", "error", "startedAt", "completedAt",
7285
+ "input", "output", "groundTruth", "metadata", "error", "startedAt", "completedAt",
7150
7286
  "retryCount", "attempt", "traceId", "status", "tags", "toolMockReport", "createdAt"
7151
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
7287
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
7152
7288
  RETURNING *`, [
7153
7289
  id,
7154
7290
  input.experimentId,
@@ -7159,6 +7295,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7159
7295
  JSON.stringify(input.input),
7160
7296
  input.output != null ? JSON.stringify(input.output) : null,
7161
7297
  input.groundTruth != null ? JSON.stringify(input.groundTruth) : null,
7298
+ input.metadata != null ? JSON.stringify(input.metadata) : null,
7162
7299
  input.error != null ? JSON.stringify(input.error) : null,
7163
7300
  input.startedAt.toISOString(),
7164
7301
  input.completedAt.toISOString(),
@@ -7173,9 +7310,9 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7173
7310
  }
7174
7311
  return t.one(`UPDATE ${tableName} SET
7175
7312
  "itemDatasetVersion" = $2, "organizationId" = $3, "projectId" = $4,
7176
- "input" = $5, "output" = $6, "groundTruth" = $7, "error" = $8,
7177
- "startedAt" = $9, "completedAt" = $10, "retryCount" = $11, "attempt" = $12,
7178
- "traceId" = $13, "status" = $14, "tags" = $15, "toolMockReport" = $16
7313
+ "input" = $5, "output" = $6, "groundTruth" = $7, "metadata" = $8, "error" = $9,
7314
+ "startedAt" = $10, "completedAt" = $11, "retryCount" = $12, "attempt" = $13,
7315
+ "traceId" = $14, "status" = $15, "tags" = $16, "toolMockReport" = $17
7179
7316
  WHERE "id" = $1 RETURNING *`, [
7180
7317
  existing.id,
7181
7318
  input.itemDatasetVersion ?? null,
@@ -7184,6 +7321,7 @@ var ExperimentsPG = class ExperimentsPG extends ExperimentsStorage {
7184
7321
  JSON.stringify(input.input),
7185
7322
  input.output != null ? JSON.stringify(input.output) : null,
7186
7323
  input.groundTruth != null ? JSON.stringify(input.groundTruth) : null,
7324
+ input.metadata != null ? JSON.stringify(input.metadata) : null,
7187
7325
  input.error != null ? JSON.stringify(input.error) : null,
7188
7326
  input.startedAt.toISOString(),
7189
7327
  input.completedAt.toISOString(),
@@ -13567,6 +13705,11 @@ const SPAN_EVENT_COLUMNS = [
13567
13705
  type: "boolean",
13568
13706
  defaultSql: "false"
13569
13707
  },
13708
+ {
13709
+ name: "isPending",
13710
+ type: "boolean",
13711
+ defaultSql: "false"
13712
+ },
13570
13713
  {
13571
13714
  name: "startedAt",
13572
13715
  type: "timestamptz"
@@ -13863,6 +14006,7 @@ const SPAN_LIGHT_SELECT_COLUMN_NAMES = [
13863
14006
  "spanType",
13864
14007
  "error",
13865
14008
  "isEvent",
14009
+ "isPending",
13866
14010
  "startedAt",
13867
14011
  "endedAt"
13868
14012
  ];
@@ -14257,6 +14401,26 @@ function allTableDDL(schema, mode) {
14257
14401
  discoveryTableDDL(schema)
14258
14402
  ];
14259
14403
  }
14404
+ /**
14405
+ * Additive column migrations for tables created by an older version of this
14406
+ * schema. `CREATE TABLE IF NOT EXISTS` is a no-op on an existing table, so new
14407
+ * columns have to be added out of band.
14408
+ *
14409
+ * `ALTER TABLE` takes an AccessExclusiveLock on the partitioned parent, so
14410
+ * callers must check {@link columnExistsSQL} first and only run the statement
14411
+ * when the column is genuinely missing. On Postgres 11+ the non-volatile
14412
+ * default does not rewrite the table, and the ALTER cascades to partitions.
14413
+ */
14414
+ function additiveColumns(schema) {
14415
+ return [{
14416
+ table: TABLE_SPAN_EVENTS,
14417
+ column: "isPending",
14418
+ ddl: `ALTER TABLE ${qualifiedTable(schema, TABLE_SPAN_EVENTS)} ADD COLUMN IF NOT EXISTS "isPending" boolean NOT NULL DEFAULT false`
14419
+ }];
14420
+ }
14421
+ /** Existence probe for an additive column. Params: schema, table, column. */
14422
+ const columnExistsSQL = `SELECT 1 FROM information_schema.columns
14423
+ WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`;
14260
14424
  /** Index CREATEs. Safe to run repeatedly. */
14261
14425
  function allIndexDDL(schema) {
14262
14426
  return tableIndexes().map((spec) => indexDDL(schema, spec));
@@ -14264,6 +14428,7 @@ function allIndexDDL(schema) {
14264
14428
  //#endregion
14265
14429
  //#region src/storage/domains/observability/v-next/discovery.ts
14266
14430
  const DEFAULT_TTL_SECONDS = 300;
14431
+ const DEFAULT_LOOKBACK_SECONDS = 720 * 60 * 60;
14267
14432
  /** All signal tables that contain a column. Used by cross-signal discovery. */
14268
14433
  const SIGNAL_TABLES_WITH_CONTEXT = [
14269
14434
  TABLE_SPAN_EVENTS,
@@ -14328,22 +14493,71 @@ function startOrJoinRefresh(dedupeKey, cacheKey, refresh, upsert, logger) {
14328
14493
  async function readWithRefresh(client, schema, cacheKey, refresh, ttlSeconds, logger) {
14329
14494
  const table = qualifiedTable(schema, TABLE_DISCOVERY);
14330
14495
  const row = await client.oneOrNone(`SELECT "values", "refreshedAt" FROM ${table} WHERE "cacheKey" = $1`, [cacheKey]);
14331
- const refreshedAtMs = row ? new Date(row.refreshedAt).getTime() : 0;
14332
- if (!(!row || Date.now() - refreshedAtMs > ttlSeconds * 1e3)) return row.values;
14333
- const refreshing = startOrJoinRefresh(`${schema}:${cacheKey}`, cacheKey, refresh, (values) => upsertCache(client, schema, cacheKey, values), logger);
14496
+ const dedupeKey = `${schema}:${cacheKey}`;
14497
+ const startRefresh = () => startOrJoinRefresh(dedupeKey, cacheKey, refresh, (values) => upsertCache(client, schema, cacheKey, values), logger);
14334
14498
  if (ttlSeconds <= 0) try {
14335
- return await refreshing;
14499
+ return await startRefresh();
14336
14500
  } catch {
14337
14501
  return row?.values ?? [];
14338
14502
  }
14503
+ const refreshedAtMs = row ? new Date(row.refreshedAt).getTime() : 0;
14504
+ if (!(!row || Date.now() - refreshedAtMs > ttlSeconds * 1e3)) return row.values;
14339
14505
  if (!row) try {
14340
- return await refreshing;
14506
+ return await startRefresh();
14341
14507
  } catch {
14342
14508
  return [];
14343
14509
  }
14344
- refreshing.catch(() => {});
14510
+ const previousRefreshedAt = row.refreshedAt;
14511
+ claimRefresh(client, schema, cacheKey, ttlSeconds).then((claimedAt) => {
14512
+ if (!claimedAt) return;
14513
+ return startRefresh().then(() => {}, () => releaseClaim(client, schema, cacheKey, previousRefreshedAt, claimedAt));
14514
+ }).catch(() => {});
14345
14515
  return row.values;
14346
14516
  }
14517
+ /**
14518
+ * Try to become the process that refreshes `cacheKey`.
14519
+ *
14520
+ * The conditional UPDATE re-checks staleness in the database and stamps
14521
+ * `refreshedAt` in the same statement, so exactly one caller can transition a
14522
+ * given cache row out of the stale window — losers see zero rows updated and
14523
+ * skip their refresh. This is the cross-process counterpart to
14524
+ * `inFlightRefreshes`: N frontends going stale together run one refresh
14525
+ * instead of N.
14526
+ *
14527
+ * It reuses the cache table rather than an advisory lock because `DbClient`
14528
+ * is pool-backed, so a transaction-scoped lock would mean threading explicit
14529
+ * transaction handling through the discovery read path.
14530
+ *
14531
+ * The stamp is provisional in both directions: a completed refresh overwrites
14532
+ * it via `upsertCache`, and a failed one restores it via `releaseClaim`.
14533
+ *
14534
+ * Returns the exact provisional stamp written by the claim (or `null` when
14535
+ * the claim was lost) so `releaseClaim` can restore the row only while it
14536
+ * still holds this claim's stamp.
14537
+ */
14538
+ async function claimRefresh(client, schema, cacheKey, ttlSeconds) {
14539
+ const table = qualifiedTable(schema, TABLE_DISCOVERY);
14540
+ return (await client.oneOrNone(`UPDATE ${table} SET "refreshedAt" = NOW()
14541
+ WHERE "cacheKey" = $1 AND "refreshedAt" <= NOW() - ($2 || ' seconds')::interval
14542
+ RETURNING "refreshedAt"::text AS "refreshedAt"`, [cacheKey, Math.floor(ttlSeconds)]))?.refreshedAt ?? null;
14543
+ }
14544
+ /**
14545
+ * Hand back a claim whose refresh failed by restoring the timestamp the
14546
+ * claiming reader observed. Guarded on the row still holding this claim's
14547
+ * exact provisional stamp so a refresh that completed in the meantime
14548
+ * (another process, or a later claim) isn't dragged backwards into staleness.
14549
+ */
14550
+ async function releaseClaim(client, schema, cacheKey, previousRefreshedAt, claimedAt) {
14551
+ const table = qualifiedTable(schema, TABLE_DISCOVERY);
14552
+ try {
14553
+ await client.query(`UPDATE ${table} SET "refreshedAt" = $2
14554
+ WHERE "cacheKey" = $1 AND "refreshedAt" = $3::timestamptz`, [
14555
+ cacheKey,
14556
+ previousRefreshedAt,
14557
+ claimedAt
14558
+ ]);
14559
+ } catch {}
14560
+ }
14347
14561
  async function upsertCache(client, schema, cacheKey, values) {
14348
14562
  const table = qualifiedTable(schema, TABLE_DISCOVERY);
14349
14563
  await client.query(`INSERT INTO ${table} ("cacheKey", "refreshedAt", "values")
@@ -14352,61 +14566,95 @@ async function upsertCache(client, schema, cacheKey, values) {
14352
14566
  "refreshedAt" = EXCLUDED."refreshedAt",
14353
14567
  "values" = EXCLUDED."values"`, [cacheKey, JSON.stringify(values)]);
14354
14568
  }
14355
- async function distinctAcrossTables(client, schema, column, tables, filterSql = "", filterParams = []) {
14569
+ /**
14570
+ * Time predicate that bounds a discovery refresh to recent events.
14571
+ *
14572
+ * Each signal table is range-partitioned on its own time column
14573
+ * (`SIGNAL_TIME_COLUMN`: `endedAt` for spans, `timestamp` for everything
14574
+ * else), so the predicate has to be built per table rather than shared across
14575
+ * a UNION. Bounding on that exact column is what lets the planner prune
14576
+ * partitions/chunks instead of scanning all history.
14577
+ *
14578
+ * The interval is interpolated rather than bound to a parameter on purpose:
14579
+ * the UNION branches in `distinctAcrossTables` and `getTags` all reference the
14580
+ * same positional placeholders, so adding a per-branch parameter would break
14581
+ * `$N` numbering. Safety comes from coercing to a finite integer here — the
14582
+ * value originates from `DiscoveryConfig`, never from user input.
14583
+ */
14584
+ function lookbackPredicate(table, lookbackSeconds) {
14585
+ if (!Number.isFinite(lookbackSeconds) || lookbackSeconds <= 0) return "";
14586
+ const seconds = Math.floor(lookbackSeconds);
14587
+ const timeColumn = SIGNAL_TIME_COLUMN[table];
14588
+ if (!timeColumn) return "";
14589
+ return `AND "${timeColumn}" >= NOW() - INTERVAL '${seconds} seconds'`;
14590
+ }
14591
+ async function distinctAcrossTables(client, schema, column, tables, lookbackSeconds, filterSql = "", filterParams = []) {
14356
14592
  const safeColumn = parseSqlIdentifier(column, "column name");
14357
- const unions = tables.map((t) => `SELECT DISTINCT "${safeColumn}" AS v FROM ${qualifiedTable(schema, t)} WHERE "${safeColumn}" IS NOT NULL AND "${safeColumn}" <> '' ${filterSql}`).join(" UNION ");
14593
+ const unions = tables.map((t) => `SELECT DISTINCT "${safeColumn}" AS v FROM ${qualifiedTable(schema, t)} WHERE "${safeColumn}" IS NOT NULL AND "${safeColumn}" <> '' ${filterSql} ${lookbackPredicate(t, lookbackSeconds)}`).join(" UNION ");
14358
14594
  return (await client.manyOrNone(`SELECT v FROM (${unions}) sub ORDER BY v`, filterParams)).map((r) => r.v);
14359
14595
  }
14360
14596
  async function getEntityTypes(client, schema, _args, config) {
14361
- return { entityTypes: await readWithRefresh(client, schema, "entity_types", () => distinctAcrossTables(client, schema, "entityType", ENTITY_DISCOVERY_TABLES), config.ttlSeconds ?? DEFAULT_TTL_SECONDS, config.logger) };
14597
+ const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14598
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14599
+ return { entityTypes: await readWithRefresh(client, schema, "entity_types", () => distinctAcrossTables(client, schema, "entityType", ENTITY_DISCOVERY_TABLES, lookback), ttl, config.logger) };
14362
14600
  }
14363
14601
  async function getEntityNames(client, schema, args, config) {
14364
14602
  const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14603
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14365
14604
  const cacheKey = args.entityType ? `entity_names:${args.entityType}` : "entity_names";
14366
14605
  const filterSql = args.entityType ? `AND "entityType" = $1` : "";
14367
14606
  const filterParams = args.entityType ? [args.entityType] : [];
14368
- return { names: await readWithRefresh(client, schema, cacheKey, () => distinctAcrossTables(client, schema, "entityName", ENTITY_DISCOVERY_TABLES, filterSql, filterParams), ttl, config.logger) };
14607
+ return { names: await readWithRefresh(client, schema, cacheKey, () => distinctAcrossTables(client, schema, "entityName", ENTITY_DISCOVERY_TABLES, lookback, filterSql, filterParams), ttl, config.logger) };
14369
14608
  }
14370
14609
  async function getServiceNames(client, schema, _args, config) {
14371
- return { serviceNames: await readWithRefresh(client, schema, "service_names", () => distinctAcrossTables(client, schema, "serviceName", SIGNAL_TABLES_WITH_CONTEXT), config.ttlSeconds ?? DEFAULT_TTL_SECONDS, config.logger) };
14610
+ const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14611
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14612
+ return { serviceNames: await readWithRefresh(client, schema, "service_names", () => distinctAcrossTables(client, schema, "serviceName", SIGNAL_TABLES_WITH_CONTEXT, lookback), ttl, config.logger) };
14372
14613
  }
14373
14614
  async function getEnvironments(client, schema, _args, config) {
14374
- return { environments: await readWithRefresh(client, schema, "environments", () => distinctAcrossTables(client, schema, "environment", SIGNAL_TABLES_WITH_CONTEXT), config.ttlSeconds ?? DEFAULT_TTL_SECONDS, config.logger) };
14615
+ const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14616
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14617
+ return { environments: await readWithRefresh(client, schema, "environments", () => distinctAcrossTables(client, schema, "environment", SIGNAL_TABLES_WITH_CONTEXT, lookback), ttl, config.logger) };
14375
14618
  }
14376
14619
  async function getTags(client, schema, args, config) {
14377
14620
  const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14621
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14378
14622
  const cacheKey = args.entityType ? `tags:${args.entityType}` : "tags";
14379
14623
  const refresh = async () => {
14380
14624
  const filter = args.entityType ? `AND "entityType" = $1` : "";
14381
14625
  const params = args.entityType ? [args.entityType] : [];
14382
- const unions = ENTITY_DISCOVERY_TABLES.map((t) => `SELECT DISTINCT UNNEST("tags") AS v FROM ${qualifiedTable(schema, t)} WHERE array_length("tags", 1) > 0 ${filter}`).join(" UNION ");
14626
+ const unions = ENTITY_DISCOVERY_TABLES.map((t) => `SELECT DISTINCT UNNEST("tags") AS v FROM ${qualifiedTable(schema, t)} WHERE array_length("tags", 1) > 0 ${filter} ${lookbackPredicate(t, lookback)}`).join(" UNION ");
14383
14627
  return (await client.manyOrNone(`SELECT v FROM (${unions}) sub WHERE v IS NOT NULL AND v <> '' ORDER BY v`, params)).map((r) => r.v);
14384
14628
  };
14385
14629
  return { tags: await readWithRefresh(client, schema, cacheKey, refresh, ttl, config.logger) };
14386
14630
  }
14387
14631
  async function getMetricNames(client, schema, args, config) {
14632
+ const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14633
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14388
14634
  let filtered = await readWithRefresh(client, schema, "metric_names", async () => {
14389
14635
  return (await client.manyOrNone(`SELECT DISTINCT "name" AS v FROM ${qualifiedTable(schema, TABLE_METRIC_EVENTS)}
14390
- WHERE "name" IS NOT NULL AND "name" <> '' ORDER BY "name"`)).map((r) => r.v);
14391
- }, config.ttlSeconds ?? DEFAULT_TTL_SECONDS, config.logger);
14636
+ WHERE "name" IS NOT NULL AND "name" <> '' ${lookbackPredicate(TABLE_METRIC_EVENTS, lookback)} ORDER BY "name"`)).map((r) => r.v);
14637
+ }, ttl, config.logger);
14392
14638
  if (args.prefix) filtered = filtered.filter((v) => v.startsWith(args.prefix));
14393
14639
  if (args.limit) filtered = filtered.slice(0, args.limit);
14394
14640
  return { names: filtered };
14395
14641
  }
14396
14642
  async function getMetricLabelKeys(client, schema, args, config) {
14397
14643
  const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14644
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14398
14645
  return { keys: await readWithRefresh(client, schema, `metric_label_keys:${args.metricName}`, async () => {
14399
14646
  return (await client.manyOrNone(`SELECT DISTINCT k AS v
14400
14647
  FROM ${qualifiedTable(schema, TABLE_METRIC_EVENTS)}, jsonb_object_keys("labels") k
14401
- WHERE "name" = $1 ORDER BY k`, [args.metricName])).map((r) => r.v);
14648
+ WHERE "name" = $1 ${lookbackPredicate(TABLE_METRIC_EVENTS, lookback)} ORDER BY k`, [args.metricName])).map((r) => r.v);
14402
14649
  }, ttl, config.logger) };
14403
14650
  }
14404
14651
  async function getMetricLabelValues(client, schema, args, config) {
14405
14652
  const ttl = config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
14653
+ const lookback = config.lookbackSeconds ?? DEFAULT_LOOKBACK_SECONDS;
14406
14654
  let filtered = await readWithRefresh(client, schema, `metric_label_values:${args.metricName}:${args.labelKey}`, async () => {
14407
14655
  return (await client.manyOrNone(`SELECT DISTINCT "labels" ->> $2 AS v
14408
14656
  FROM ${qualifiedTable(schema, TABLE_METRIC_EVENTS)}
14409
- WHERE "name" = $1 AND "labels" ? $2
14657
+ WHERE "name" = $1 AND "labels" ? $2 ${lookbackPredicate(TABLE_METRIC_EVENTS, lookback)}
14410
14658
  ORDER BY v`, [args.metricName, args.labelKey])).map((r) => r.v).filter((v) => v != null && v !== "");
14411
14659
  }, ttl, config.logger);
14412
14660
  if (args.prefix) filtered = filtered.filter((v) => v.startsWith(args.prefix));
@@ -14645,6 +14893,7 @@ function commonContextToRow(record) {
14645
14893
  };
14646
14894
  }
14647
14895
  function spanRecordToRow(span) {
14896
+ const isPending = !span.isEvent && span.endedAt == null;
14648
14897
  const endedAt = span.isEvent ? span.startedAt : span.endedAt ?? span.startedAt;
14649
14898
  const metadata = span.metadata ?? null;
14650
14899
  return {
@@ -14655,6 +14904,7 @@ function spanRecordToRow(span) {
14655
14904
  name: span.name,
14656
14905
  spanType: span.spanType,
14657
14906
  isEvent: Boolean(span.isEvent),
14907
+ isPending,
14658
14908
  startedAt: toIsoOrDate(span.startedAt),
14659
14909
  endedAt: toIsoOrDate(endedAt),
14660
14910
  tags: normalizeTags(span.tags),
@@ -14669,9 +14919,19 @@ function spanRecordToRow(span) {
14669
14919
  requestContext: jsonField(span.requestContext)
14670
14920
  };
14671
14921
  }
14922
+ /**
14923
+ * Recover a span's real `endedAt` from its row. A pending row carries a
14924
+ * synthesized `endedAt` (a copy of `startedAt`) because the column is part of
14925
+ * the primary key; callers must see null so the span reads as still running.
14926
+ */
14927
+ function spanRowEndedAt(row, startedAt) {
14928
+ if (row.isEvent) return startedAt;
14929
+ if (row.isPending) return null;
14930
+ return toDateOrNull(row.endedAt);
14931
+ }
14672
14932
  function rowToSpanRecord(row) {
14673
14933
  const startedAt = toDate(row.startedAt);
14674
- const endedAt = row.isEvent ? startedAt : toDateOrNull(row.endedAt);
14934
+ const endedAt = spanRowEndedAt(row, startedAt);
14675
14935
  const error = parsedJson(row.error);
14676
14936
  const { executionSource, ...ctx } = rowToCommonContext(row);
14677
14937
  return {
@@ -14705,7 +14965,7 @@ function rowToSpanRecord(row) {
14705
14965
  */
14706
14966
  function rowToLightSpanRecord(row) {
14707
14967
  const startedAt = toDate(row.startedAt);
14708
- const endedAt = row.isEvent ? startedAt : toDateOrNull(row.endedAt);
14968
+ const endedAt = spanRowEndedAt(row, startedAt);
14709
14969
  return {
14710
14970
  traceId: row.traceId,
14711
14971
  spanId: row.spanId,
@@ -15236,22 +15496,42 @@ const COMPLEX_GROUP_BY_EXCLUDED = /* @__PURE__ */ new Set([
15236
15496
  "requestContext"
15237
15497
  ]);
15238
15498
  //#endregion
15499
+ //#region src/storage/db/sanitize-json.ts
15500
+ /**
15501
+ * Sanitizes JSON string for PostgreSQL jsonb:
15502
+ * - Removes problematic Unicode sequences:
15503
+ * - \u0000 (null character) - causes error 22P05 "unsupported Unicode escape sequence"
15504
+ * - \uD800-\uDFFF (unpaired surrogates) - causes "Unicode low surrogate must follow a high surrogate"
15505
+ * - \\uD800 (escaped-backslash + surrogate, e.g. from JS regex literals like [^\ud800-\udfff]):
15506
+ * removing just \uXXXX would leave a dangling backslash that creates a new invalid escape (e.g. \-)
15507
+ * - Escapes any remaining invalid JSON escape sequences (e.g. \v, \k, \-)
15508
+ */
15509
+ function sanitizeJsonForPg(jsonString) {
15510
+ return jsonString.replace(/\\\\?u(0000|[Dd][89A-Fa-f][0-9A-Fa-f]{2})/g, "").replace(/(^|[^\\])(\\(?!["\\/bfnrtu]))/g, "$1\\\\");
15511
+ }
15512
+ //#endregion
15239
15513
  //#region src/storage/domains/observability/v-next/sql.ts
15240
15514
  /**
15241
15515
  * SQL helpers for the v-next Postgres observability domain.
15242
15516
  *
15243
- * Provides a multi-row INSERT builder with `ON CONFLICT DO NOTHING` for
15244
- * insert-only retry idempotency, and explicit jsonb / text[] casts so the
15245
- * pg driver doesn't have to guess column types.
15517
+ * Provides a multi-row INSERT builder with a configurable conflict clause
15518
+ * (`ON CONFLICT DO NOTHING` by default, for retry idempotency), and explicit
15519
+ * jsonb / text[] casts so the pg driver doesn't have to guess column types.
15246
15520
  */
15247
15521
  /**
15248
15522
  * Encode a JS value for a `$N::jsonb` cast. Always `JSON.stringify` so a
15249
15523
  * plain string like `"hello"` becomes `"hello"` (a valid JSON scalar) and
15250
15524
  * not the bare word `hello`, which Postgres rejects when cast to jsonb.
15525
+ *
15526
+ * The result is sanitized because Postgres rejects some sequences that are
15527
+ * legal in JSON: NUL (`\u0000`) fails with 22P05 and unpaired UTF-16
15528
+ * surrogates fail with 22P02. Inserts here are batched into a single
15529
+ * multi-row statement, so one bad value would otherwise reject the whole
15530
+ * batch of observability events.
15251
15531
  */
15252
15532
  function encodeJsonb(value) {
15253
15533
  if (value === null || value === void 0) return null;
15254
- return JSON.stringify(value);
15534
+ return sanitizeJsonForPg(JSON.stringify(value));
15255
15535
  }
15256
15536
  /**
15257
15537
  * Build a multi-row INSERT with explicit column types and ON CONFLICT DO NOTHING.
@@ -15262,7 +15542,7 @@ function encodeJsonb(value) {
15262
15542
  * All records must have identical key sets.
15263
15543
  * @returns { text, values } ready to pass to `client.query`.
15264
15544
  */
15265
- function buildInsert(schema, table, records) {
15545
+ function buildInsert(schema, table, records, onConflict = "ON CONFLICT DO NOTHING") {
15266
15546
  if (records.length === 0) return null;
15267
15547
  const columns = Object.keys(records[0]).map((c) => parseSqlIdentifier(c, "column name"));
15268
15548
  const quotedColumns = columns.map((c) => `"${c}"`).join(", ");
@@ -15288,10 +15568,44 @@ function buildInsert(schema, table, records) {
15288
15568
  return {
15289
15569
  text: `INSERT INTO ${qualifiedTable(schema, table)} (${quotedColumns})
15290
15570
  VALUES ${rowPlaceholders.join(", ")}
15291
- ON CONFLICT DO NOTHING`,
15571
+ ${onConflict}`,
15292
15572
  values
15293
15573
  };
15294
15574
  }
15575
+ /** Primary key of the span table; also the conflict target for span upserts. */
15576
+ const SPAN_KEY_COLUMNS = [
15577
+ "traceId",
15578
+ "spanId",
15579
+ "endedAt"
15580
+ ];
15581
+ /**
15582
+ * Conflict clause for span writes.
15583
+ *
15584
+ * A span is written twice under the event-sourced strategy: once when it starts
15585
+ * (`isPending = true`, `endedAt` synthesized from `startedAt`) and once when it
15586
+ * ends. Those two rows normally differ in `endedAt` and so coexist under the
15587
+ * primary key. A zero-duration span is the exception — its real `endedAt`
15588
+ * equals its `startedAt`, so the end row collides with the pending row. Letting
15589
+ * `DO NOTHING` win there would strand the span as permanently running, so the
15590
+ * end row overwrites the pending one instead.
15591
+ *
15592
+ * The `WHERE` guard keeps the clause a no-op for every other conflict, which
15593
+ * preserves insert-only retry idempotency: a replayed end row cannot clobber
15594
+ * the row it duplicates, and a late-arriving start row cannot revert an ended
15595
+ * span to pending.
15596
+ */
15597
+ function spanConflictClause(columns) {
15598
+ return `ON CONFLICT ("traceId", "spanId", "endedAt") DO UPDATE SET ${columns.filter((column) => !SPAN_KEY_COLUMNS.includes(column)).map((column) => `"${column}" = EXCLUDED."${column}"`).join(", ")}
15599
+ WHERE ${TABLE_SPAN_EVENTS}."isPending" AND NOT EXCLUDED."isPending"`;
15600
+ }
15601
+ /**
15602
+ * Ordering that picks the winning row for a span under the event-sourced write
15603
+ * model. An ended row always beats a pending row for the same span; among rows
15604
+ * of the same kind the latest write wins, with `cursorId` breaking ties between
15605
+ * rows that share a timestamp. Combine with `DISTINCT ON` (or `LIMIT 1`) after
15606
+ * the grouping key to collapse a span's rows down to one.
15607
+ */
15608
+ const SPAN_COLLAPSE_ORDER = "\"isPending\" ASC, \"endedAt\" DESC, \"cursorId\" DESC";
15295
15609
  /**
15296
15610
  * Standard SELECT column list for tracing tables. The select projects every
15297
15611
  * column the row→record converters expect.
@@ -16287,12 +16601,51 @@ async function getScorePercentiles(client, schema, args) {
16287
16601
  function asIsoTimestamp(value) {
16288
16602
  return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
16289
16603
  }
16604
+ /**
16605
+ * Ordering that picks the row representing a trace's current root.
16606
+ *
16607
+ * A trace can hold more than one root row. A span is written once when it
16608
+ * starts and again when it ends, and a durable run that suspends ends its root
16609
+ * span and opens a fresh one on resume. In both cases the newest row is the one
16610
+ * that describes the trace right now, and `cursorId` — a sequence assigned at
16611
+ * insert — is what "newest" means here. It also orders correctly where
16612
+ * timestamps cannot: a zero-duration span has `startedAt = endedAt`, and a
16613
+ * resumed root starts after the suspended root ended.
16614
+ */
16615
+ const ROOT_COLLAPSE_ORDER = "\"cursorId\" DESC";
16616
+ /**
16617
+ * Predicate that keeps only the winning row for each span — the ended row when
16618
+ * one exists, the newest row otherwise. Mirrors `SPAN_COLLAPSE_ORDER` as an
16619
+ * anti-join, for the same reason as {@link latestRootPredicate}.
16620
+ */
16621
+ function latestSpanPredicate(spanTable) {
16622
+ return `NOT EXISTS (
16623
+ SELECT 1 FROM ${spanTable} newer
16624
+ WHERE newer."traceId" = r."traceId"
16625
+ AND newer."spanId" = r."spanId"
16626
+ AND (newer."isPending" < r."isPending" OR (newer."isPending" = r."isPending" AND newer."cursorId" > r."cursorId"))
16627
+ )`;
16628
+ }
16629
+ /**
16630
+ * Predicate that keeps only a trace's current root row, as an anti-join rather
16631
+ * than a `DISTINCT ON`. Written this way so the outer query still scans the
16632
+ * partial root indexes and streams rows in `LIMIT`-order; grouping first would
16633
+ * force every root in the table to be read before the first page came back.
16634
+ */
16635
+ function latestRootPredicate(spanTable) {
16636
+ return `NOT EXISTS (
16637
+ SELECT 1 FROM ${spanTable} newer
16638
+ WHERE newer."traceId" = r."traceId"
16639
+ AND newer."parentSpanId" IS NULL
16640
+ AND newer."cursorId" > r."cursorId"
16641
+ )`;
16642
+ }
16290
16643
  async function getRootSpan(client, schema, args) {
16291
16644
  const table = qualifiedTable(schema, TABLE_SPAN_EVENTS);
16292
16645
  const row = await client.oneOrNone(`SELECT ${SPAN_SELECT_COLUMNS}
16293
16646
  FROM ${table}
16294
16647
  WHERE "traceId" = $1 AND "parentSpanId" IS NULL
16295
- ORDER BY "endedAt" DESC
16648
+ ORDER BY ${ROOT_COLLAPSE_ORDER}
16296
16649
  LIMIT 1`, [args.traceId]);
16297
16650
  if (!row) return null;
16298
16651
  return { span: rowToSpanRecord(row) };
@@ -16304,7 +16657,7 @@ async function getRootSpan(client, schema, args) {
16304
16657
  * planner. Starts numbering from `nextParamIdx`.
16305
16658
  */
16306
16659
  function buildListTracesFilters(filters, spanTable, nextParamIdx) {
16307
- const conditions = [`r."parentSpanId" IS NULL`];
16660
+ const conditions = [`r."parentSpanId" IS NULL`, latestRootPredicate(spanTable)];
16308
16661
  const params = [];
16309
16662
  let i = nextParamIdx;
16310
16663
  if (!filters) return {
@@ -16321,11 +16674,11 @@ function buildListTracesFilters(filters, spanTable, nextParamIdx) {
16321
16674
  params.push(asIsoTimestamp(filters.startedAt.end));
16322
16675
  }
16323
16676
  if (filters.endedAt?.start) {
16324
- conditions.push(`r."endedAt" ${filters.endedAt.startExclusive ? ">" : ">="} $${i++}`);
16677
+ conditions.push(`(NOT r."isPending" AND r."endedAt" ${filters.endedAt.startExclusive ? ">" : ">="} $${i++})`);
16325
16678
  params.push(asIsoTimestamp(filters.endedAt.start));
16326
16679
  }
16327
16680
  if (filters.endedAt?.end) {
16328
- conditions.push(`r."endedAt" ${filters.endedAt.endExclusive ? "<" : "<="} $${i++}`);
16681
+ conditions.push(`(NOT r."isPending" AND r."endedAt" ${filters.endedAt.endExclusive ? "<" : "<="} $${i++})`);
16329
16682
  params.push(asIsoTimestamp(filters.endedAt.end));
16330
16683
  }
16331
16684
  if (filters.spanType !== void 0) {
@@ -16397,10 +16750,10 @@ function buildListTracesFilters(filters, spanTable, nextParamIdx) {
16397
16750
  conditions.push(`r."error" IS NOT NULL`);
16398
16751
  break;
16399
16752
  case TraceStatus.RUNNING:
16400
- conditions.push(`FALSE`);
16753
+ conditions.push(`r."isPending"`);
16401
16754
  break;
16402
16755
  case TraceStatus.SUCCESS:
16403
- conditions.push(`r."error" IS NULL`);
16756
+ conditions.push(`r."error" IS NULL AND NOT r."isPending"`);
16404
16757
  break;
16405
16758
  }
16406
16759
  if (filters.hasChildError !== void 0) {
@@ -16537,8 +16890,8 @@ function buildBranchSpanTypeClause(userSpanType, params, startIdx) {
16537
16890
  * NOT have a `parentSpanId IS NULL` predicate — branches can be nested under
16538
16891
  * other spans. Returns the bound params and the next param index.
16539
16892
  */
16540
- function buildListBranchesFilters(filters, spanType, nextParamIdx) {
16541
- const conditions = [];
16893
+ function buildListBranchesFilters(filters, spanType, spanTable, nextParamIdx) {
16894
+ const conditions = [latestSpanPredicate(spanTable)];
16542
16895
  const params = [];
16543
16896
  let i = nextParamIdx;
16544
16897
  const spanTypeClause = buildBranchSpanTypeClause(spanType, params, i);
@@ -16559,11 +16912,11 @@ function buildListBranchesFilters(filters, spanType, nextParamIdx) {
16559
16912
  params.push(asIsoTimestamp(filters.startedAt.end));
16560
16913
  }
16561
16914
  if (filters.endedAt?.start) {
16562
- conditions.push(`r."endedAt" ${filters.endedAt.startExclusive ? ">" : ">="} $${i++}`);
16915
+ conditions.push(`(NOT r."isPending" AND r."endedAt" ${filters.endedAt.startExclusive ? ">" : ">="} $${i++})`);
16563
16916
  params.push(asIsoTimestamp(filters.endedAt.start));
16564
16917
  }
16565
16918
  if (filters.endedAt?.end) {
16566
- conditions.push(`r."endedAt" ${filters.endedAt.endExclusive ? "<" : "<="} $${i++}`);
16919
+ conditions.push(`(NOT r."isPending" AND r."endedAt" ${filters.endedAt.endExclusive ? "<" : "<="} $${i++})`);
16567
16920
  params.push(asIsoTimestamp(filters.endedAt.end));
16568
16921
  }
16569
16922
  if (filters.traceId !== void 0) {
@@ -16675,10 +17028,10 @@ function buildListBranchesFilters(filters, spanType, nextParamIdx) {
16675
17028
  conditions.push(`r."error" IS NOT NULL`);
16676
17029
  break;
16677
17030
  case TraceStatus.RUNNING:
16678
- conditions.push(`FALSE`);
17031
+ conditions.push(`r."isPending"`);
16679
17032
  break;
16680
17033
  case TraceStatus.SUCCESS:
16681
- conditions.push(`r."error" IS NULL`);
17034
+ conditions.push(`r."error" IS NULL AND NOT r."isPending"`);
16682
17035
  break;
16683
17036
  }
16684
17037
  return {
@@ -16697,7 +17050,7 @@ async function listBranches(client, schema, args) {
16697
17050
  return listBranchesPage(client, span, filters, pagination.page, pagination.perPage, orderBy.field, orderBy.direction);
16698
17051
  }
16699
17052
  async function listBranchesPage(client, span, filters, page, perPage, orderField, orderDir) {
16700
- const built = buildListBranchesFilters(filters, filters?.spanType, 1);
17053
+ const built = buildListBranchesFilters(filters, filters?.spanType, span, 1);
16701
17054
  if (!built) {
16702
17055
  const deltaCursor = deltaPollingFeatureEnabled() ? await readBranchesStreamHeadCursor(client, span, filters) : void 0;
16703
17056
  return {
@@ -16751,7 +17104,7 @@ async function listBranchesDelta(client, span, filters, after, limit) {
16751
17104
  deltaCursor
16752
17105
  };
16753
17106
  }
16754
- const built = buildListBranchesFilters(filters, filters?.spanType, 1);
17107
+ const built = buildListBranchesFilters(filters, filters?.spanType, span, 1);
16755
17108
  if (!built) return {
16756
17109
  branches: [],
16757
17110
  delta: {
@@ -16794,14 +17147,35 @@ async function readBranchesStreamHeadCursor(client, span, filters) {
16794
17147
  }
16795
17148
  //#endregion
16796
17149
  //#region src/storage/domains/observability/v-next/tracing.ts
16797
- async function createSpan(client, schema, args) {
16798
- const insert = buildInsert(schema, TABLE_SPAN_EVENTS, [spanRecordToRow(args.span)]);
17150
+ async function writeSpanRows(client, schema, rows) {
17151
+ const deduped = dedupeSpanRows(rows);
17152
+ const insert = buildInsert(schema, TABLE_SPAN_EVENTS, deduped, spanConflictClause(Object.keys(deduped[0] ?? {})));
16799
17153
  if (insert) await client.query(insert.text, insert.values);
16800
17154
  }
17155
+ /**
17156
+ * Collapse rows that share a primary key within a single batch. Postgres
17157
+ * rejects an `ON CONFLICT DO UPDATE` statement whose VALUES list would touch
17158
+ * the same row twice, which a batch containing both the start and the end of a
17159
+ * zero-duration span would do. The ended row wins, matching what the conflict
17160
+ * clause would have done across two separate statements.
17161
+ */
17162
+ function dedupeSpanRows(rows) {
17163
+ if (rows.length < 2) return rows;
17164
+ const byKey = /* @__PURE__ */ new Map();
17165
+ for (const row of rows) {
17166
+ const key = `${row.traceId}\u0000${row.spanId}\u0000${String(row.endedAt)}`;
17167
+ const existing = byKey.get(key);
17168
+ if (existing && !existing.isPending) continue;
17169
+ byKey.set(key, row);
17170
+ }
17171
+ return [...byKey.values()];
17172
+ }
17173
+ async function createSpan(client, schema, args) {
17174
+ await writeSpanRows(client, schema, [spanRecordToRow(args.span)]);
17175
+ }
16801
17176
  async function batchCreateSpans(client, schema, args) {
16802
17177
  if (args.records.length === 0) return;
16803
- const insert = buildInsert(schema, TABLE_SPAN_EVENTS, args.records.map(spanRecordToRow));
16804
- if (insert) await client.query(insert.text, insert.values);
17178
+ await writeSpanRows(client, schema, args.records.map(spanRecordToRow));
16805
17179
  }
16806
17180
  async function getSpans(client, schema, args) {
16807
17181
  if (args.spanIds.length === 0) return {
@@ -16809,10 +17183,13 @@ async function getSpans(client, schema, args) {
16809
17183
  spans: []
16810
17184
  };
16811
17185
  const table = qualifiedTable(schema, TABLE_SPAN_EVENTS);
16812
- const rows = await client.manyOrNone(`SELECT ${SPAN_SELECT_COLUMNS}
16813
- FROM ${table}
16814
- WHERE "traceId" = $1
16815
- AND "spanId" = ANY($2::text[])
17186
+ const rows = await client.manyOrNone(`SELECT * FROM (
17187
+ SELECT DISTINCT ON ("spanId") ${SPAN_SELECT_COLUMNS}
17188
+ FROM ${table}
17189
+ WHERE "traceId" = $1
17190
+ AND "spanId" = ANY($2::text[])
17191
+ ORDER BY "spanId", ${SPAN_COLLAPSE_ORDER}
17192
+ ) collapsed
16816
17193
  ORDER BY "startedAt" ASC`, [args.traceId, args.spanIds]);
16817
17194
  return {
16818
17195
  traceId: args.traceId,
@@ -16824,16 +17201,19 @@ async function getSpan(client, schema, args) {
16824
17201
  const row = await client.oneOrNone(`SELECT ${SPAN_SELECT_COLUMNS}
16825
17202
  FROM ${table}
16826
17203
  WHERE "traceId" = $1 AND "spanId" = $2
16827
- ORDER BY "endedAt" DESC
17204
+ ORDER BY ${SPAN_COLLAPSE_ORDER}
16828
17205
  LIMIT 1`, [args.traceId, args.spanId]);
16829
17206
  if (!row) return null;
16830
17207
  return { span: rowToSpanRecord(row) };
16831
17208
  }
16832
17209
  async function getTrace(client, schema, args) {
16833
17210
  const table = qualifiedTable(schema, TABLE_SPAN_EVENTS);
16834
- const rows = await client.manyOrNone(`SELECT ${SPAN_SELECT_COLUMNS}
16835
- FROM ${table}
16836
- WHERE "traceId" = $1
17211
+ const rows = await client.manyOrNone(`SELECT * FROM (
17212
+ SELECT DISTINCT ON ("spanId") ${SPAN_SELECT_COLUMNS}
17213
+ FROM ${table}
17214
+ WHERE "traceId" = $1
17215
+ ORDER BY "spanId", ${SPAN_COLLAPSE_ORDER}
17216
+ ) collapsed
16837
17217
  ORDER BY "startedAt" ASC`, [args.traceId]);
16838
17218
  if (!rows.length) return null;
16839
17219
  return {
@@ -16843,9 +17223,12 @@ async function getTrace(client, schema, args) {
16843
17223
  }
16844
17224
  async function getTraceLight(client, schema, args) {
16845
17225
  const table = qualifiedTable(schema, TABLE_SPAN_EVENTS);
16846
- const rows = await client.manyOrNone(`SELECT ${SPAN_LIGHT_SELECT_COLUMNS}
16847
- FROM ${table}
16848
- WHERE "traceId" = $1
17226
+ const rows = await client.manyOrNone(`SELECT * FROM (
17227
+ SELECT DISTINCT ON ("spanId") ${SPAN_LIGHT_SELECT_COLUMNS}
17228
+ FROM ${table}
17229
+ WHERE "traceId" = $1
17230
+ ORDER BY "spanId", ${SPAN_COLLAPSE_ORDER}
17231
+ ) collapsed
16849
17232
  ORDER BY "startedAt" ASC`, [args.traceId]);
16850
17233
  if (!rows.length) return null;
16851
17234
  return {
@@ -16956,6 +17339,11 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
16956
17339
  } catch (error) {
16957
17340
  if (!isDuplicateRelationError(error)) throw error;
16958
17341
  }
17342
+ for (const { table, column, ddl } of additiveColumns(this.#schema)) if (!await this.#client.oneOrNone(columnExistsSQL, [
17343
+ this.#schema,
17344
+ table,
17345
+ column
17346
+ ])) await this.#client.none(ddl);
16959
17347
  for (const ddl of allIndexDDL(this.#schema)) try {
16960
17348
  await this.#client.none(ddl);
16961
17349
  } catch (error) {
@@ -17052,8 +17440,8 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
17052
17440
  }
17053
17441
  get observabilityStrategy() {
17054
17442
  return {
17055
- preferred: "insert-only",
17056
- supported: ["insert-only"]
17443
+ preferred: "event-sourced",
17444
+ supported: ["event-sourced"]
17057
17445
  };
17058
17446
  }
17059
17447
  getFeatures() {
@@ -20477,18 +20865,6 @@ function workflowSnapshotStatusIndexSQL(indexName, schemaName) {
20477
20865
  schemaName: getSchemaName(schemaName)
20478
20866
  })} (workflow_name, (snapshot ->> 'status'), "createdAt" DESC)`;
20479
20867
  }
20480
- /**
20481
- * Sanitizes JSON string for PostgreSQL jsonb:
20482
- * - Removes problematic Unicode sequences:
20483
- * - \u0000 (null character) - causes error 22P05 "unsupported Unicode escape sequence"
20484
- * - \uD800-\uDFFF (unpaired surrogates) - causes "Unicode low surrogate must follow a high surrogate"
20485
- * - \\uD800 (escaped-backslash + surrogate, e.g. from JS regex literals like [^\ud800-\udfff]):
20486
- * removing just \uXXXX would leave a dangling backslash that creates a new invalid escape (e.g. \-)
20487
- * - Escapes any remaining invalid JSON escape sequences (e.g. \v, \k, \-)
20488
- */
20489
- function sanitizeJsonForPg(jsonString) {
20490
- return jsonString.replace(/\\\\?u(0000|[Dd][89A-Fa-f][0-9A-Fa-f]{2})/g, "").replace(/(^|[^\\])(\\(?!["\\/bfnrtu]))/g, "$1\\\\");
20491
- }
20492
20868
  var WorkflowsPG = class WorkflowsPG extends WorkflowsStorage {
20493
20869
  #db;
20494
20870
  #schema;
@@ -20768,11 +21144,11 @@ var WorkflowsPG = class WorkflowsPG extends WorkflowsStorage {
20768
21144
  await this.#db.client.none(`INSERT INTO ${getTableName({
20769
21145
  indexName: TABLE_WORKFLOW_SNAPSHOT,
20770
21146
  schemaName: getSchemaName(this.#schema)
20771
- })}
21147
+ })} AS t
20772
21148
  (workflow_name, run_id, "resourceId", snapshot, "createdAt", "updatedAt", "createdAtZ", "updatedAtZ")
20773
21149
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
20774
21150
  ON CONFLICT (workflow_name, run_id) DO UPDATE
20775
- SET "resourceId" = $3, snapshot = $4, "updatedAt" = $6, "updatedAtZ" = $8`, [
21151
+ SET "resourceId" = COALESCE($3, t."resourceId"), snapshot = $4, "updatedAt" = $6, "updatedAtZ" = $8`, [
20776
21152
  workflowName,
20777
21153
  runId,
20778
21154
  resourceId,
@@ -21626,7 +22002,7 @@ var PgFactoryStorageOps = class {
21626
22002
  row[name] = Boolean(value);
21627
22003
  break;
21628
22004
  case "json":
21629
- row[name] = typeof value === "string" ? JSON.parse(value) : value;
22005
+ row[name] = value;
21630
22006
  break;
21631
22007
  case "bigint":
21632
22008
  case "integer":
@@ -21730,6 +22106,12 @@ var PgFactoryStorageOps = class {
21730
22106
  async findMany(collection, where, opts) {
21731
22107
  return this.#select(this.#queryable, collection, where, opts);
21732
22108
  }
22109
+ async count(collection, where) {
22110
+ const schema = this.#schema(collection);
22111
+ const filter = this.#buildWhere(schema, where);
22112
+ const result = await this.#queryable.query(`SELECT COUNT(*) AS count FROM "${schema.name}" WHERE ${filter.sql}`, filter.args);
22113
+ return Number(result.rows[0]?.count ?? 0);
22114
+ }
21733
22115
  async insertOne(collection, row) {
21734
22116
  const schema = this.#schema(collection);
21735
22117
  const pk = primaryKeyOf(schema);