@mastra/pg 1.33.0-alpha.3 → 1.34.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +584 -271
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +586 -273
- package/dist/index.js.map +1 -1
- package/dist/storage/client.d.ts +1 -0
- package/dist/storage/client.d.ts.map +1 -1
- package/dist/storage/db/index-build.d.ts +5 -0
- package/dist/storage/db/index-build.d.ts.map +1 -0
- package/dist/storage/db/index.d.ts +0 -8
- package/dist/storage/db/index.d.ts.map +1 -1
- package/dist/storage/db/pg-errors.d.ts +6 -0
- package/dist/storage/db/pg-errors.d.ts.map +1 -1
- package/dist/storage/db/schema-snapshot.d.ts +4 -7
- package/dist/storage/db/schema-snapshot.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/ddl.d.ts +8 -1
- package/dist/storage/domains/observability/v-next/ddl.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/feedback.d.ts +3 -2
- package/dist/storage/domains/observability/v-next/feedback.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/helpers.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/index.d.ts +9 -1
- package/dist/storage/domains/observability/v-next/index.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/logs.d.ts +2 -1
- package/dist/storage/domains/observability/v-next/logs.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/metrics.d.ts +2 -1
- package/dist/storage/domains/observability/v-next/metrics.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/partitioning.d.ts +29 -19
- package/dist/storage/domains/observability/v-next/partitioning.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/preview-compat.d.ts +1 -0
- package/dist/storage/domains/observability/v-next/preview-compat.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/scores.d.ts +3 -2
- package/dist/storage/domains/observability/v-next/scores.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/signal-schema.d.ts +37 -1
- package/dist/storage/domains/observability/v-next/signal-schema.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/span-query.d.ts +1 -0
- package/dist/storage/domains/observability/v-next/span-query.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/sql.d.ts +12 -0
- package/dist/storage/domains/observability/v-next/sql.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/thread-query.d.ts +1 -0
- package/dist/storage/domains/observability/v-next/thread-query.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/trace-query.d.ts +3 -1
- package/dist/storage/domains/observability/v-next/trace-query.d.ts.map +1 -1
- package/dist/storage/domains/observability/v-next/tracing.d.ts +3 -2
- package/dist/storage/domains/observability/v-next/tracing.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/vector/sql-builder.d.ts.map +1 -1
- package/package.json +6 -6
- package/dist/docs/SKILL.md +0 -48
- package/dist/docs/assets/SOURCE_MAP.json +0 -6
- package/dist/docs/references/docs-deployment-workers.md +0 -404
- package/dist/docs/references/docs-memory-semantic-recall.md +0 -481
- package/dist/docs/references/docs-memory-working-memory.md +0 -450
- package/dist/docs/references/docs-storage.md +0 -224
- package/dist/docs/references/integrations-databases-clickhouse-managed-postgres.md +0 -82
- package/dist/docs/references/integrations-databases-dynamodb.md +0 -286
- package/dist/docs/references/integrations-databases-neon.md +0 -222
- package/dist/docs/references/integrations-databases-postgresql.md +0 -561
- package/dist/docs/references/reference-memory-memory-class.md +0 -158
- package/dist/docs/references/reference-processors-message-history-processor.md +0 -89
- package/dist/docs/references/reference-processors-semantic-recall-processor.md +0 -119
- package/dist/docs/references/reference-processors-working-memory-processor.md +0 -154
- package/dist/docs/references/reference-rag-chunking-and-embedding.md +0 -184
- package/dist/docs/references/reference-rag-metadata-filters.md +0 -238
- package/dist/docs/references/reference-rag-overview.md +0 -76
- package/dist/docs/references/reference-rag-retrieval.md +0 -668
- package/dist/docs/references/reference-rag-vector-databases.md +0 -808
- package/dist/docs/references/reference-storage-composite.md +0 -433
- package/dist/docs/references/reference-storage-retention.md +0 -310
- package/dist/docs/references/reference-tools-vector-query-tool.md +0 -500
- package/dist/docs/references/reference-vectors-pg.md +0 -439
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ let pg_connection_string = require("pg-connection-string");
|
|
|
34
34
|
let _mastra_core_vector_filter = require("@mastra/core/vector/filter");
|
|
35
35
|
let _mastra_core_base = require("@mastra/core/base");
|
|
36
36
|
let crypto$1 = require("crypto");
|
|
37
|
+
let timers_promises = require("timers/promises");
|
|
37
38
|
let _mastra_core_features = require("@mastra/core/features");
|
|
38
39
|
let _mastra_core_agent = require("@mastra/core/agent");
|
|
39
40
|
let _mastra_core_evals = require("@mastra/core/evals");
|
|
@@ -262,7 +263,7 @@ const createNumericOperator = (symbol) => {
|
|
|
262
263
|
sql: `(CASE WHEN jsonb_typeof(${jsonExtract}) = 'number' THEN (${textExtract})::numeric ${symbol} $${paramIndex}::numeric ELSE NULL END)`,
|
|
263
264
|
needsValue: true
|
|
264
265
|
};
|
|
265
|
-
|
|
266
|
+
return {
|
|
266
267
|
sql: `${textExtract} ${symbol} $${paramIndex}::text`,
|
|
267
268
|
needsValue: true
|
|
268
269
|
};
|
|
@@ -2276,6 +2277,9 @@ var PinnedClientAdapter = class {
|
|
|
2276
2277
|
this.#tail = next.catch(() => void 0);
|
|
2277
2278
|
return next;
|
|
2278
2279
|
}
|
|
2280
|
+
async drain() {
|
|
2281
|
+
await this.#tail;
|
|
2282
|
+
}
|
|
2279
2283
|
connect() {
|
|
2280
2284
|
throw new Error("PinnedClientAdapter.connect() is not supported during PostgresStore.init(). All DDL must flow through the pinned client.");
|
|
2281
2285
|
}
|
|
@@ -2458,6 +2462,39 @@ function buildConstraintName({ baseName, schemaName, maxLength = 63, hashWhenTru
|
|
|
2458
2462
|
return truncateIdentifier(fullName, maxLength);
|
|
2459
2463
|
}
|
|
2460
2464
|
// src/storage/db/pg-errors.ts
|
|
2465
|
+
/**
|
|
2466
|
+
* Postgres error classifiers for concurrent DDL.
|
|
2467
|
+
*
|
|
2468
|
+
* `CREATE SCHEMA / TABLE / INDEX IF NOT EXISTS` checks aren't atomic against
|
|
2469
|
+
* concurrent backends. Two callers racing past the existence probe can both
|
|
2470
|
+
* reach the catalog insert; the loser sees a duplicate-object error. These
|
|
2471
|
+
* helpers identify the exact codes we treat as "already exists by the time we
|
|
2472
|
+
* look" so every other error surfaces normally.
|
|
2473
|
+
*/
|
|
2474
|
+
var IndexBuildError = class extends Error {
|
|
2475
|
+
constructor(indexName, schemaName, state, cause) {
|
|
2476
|
+
super(`Could not create index ${schemaName}.${indexName}: ${state}`, { cause });
|
|
2477
|
+
this.name = "IndexBuildError";
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
function* causes(error) {
|
|
2481
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2482
|
+
while (typeof error === "object" && error !== null && !seen.has(error)) {
|
|
2483
|
+
seen.add(error);
|
|
2484
|
+
yield error;
|
|
2485
|
+
error = "cause" in error ? error.cause : void 0;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
/** Index recovery owns its retry budget, even through an outer MastraError. */
|
|
2489
|
+
function isRetryableDeadlock(error) {
|
|
2490
|
+
for (const cause of causes(error)) if (cause instanceof IndexBuildError) return false;
|
|
2491
|
+
for (const cause of causes(error)) if ("code" in cause && cause.code === "40P01") return true;
|
|
2492
|
+
return false;
|
|
2493
|
+
}
|
|
2494
|
+
async function backoffAfterDeadlock(attempt) {
|
|
2495
|
+
const base = 50 * 2 ** (attempt - 1);
|
|
2496
|
+
await new Promise((resolve) => setTimeout(resolve, base + Math.random() * base * 2));
|
|
2497
|
+
}
|
|
2461
2498
|
function asPgError(error) {
|
|
2462
2499
|
return error ?? {};
|
|
2463
2500
|
}
|
|
@@ -2485,6 +2522,99 @@ function isDuplicateSchemaError(error) {
|
|
|
2485
2522
|
if (code === "23505" && constraint === "pg_namespace_nspname_index") return true;
|
|
2486
2523
|
return /schema .* already exists/i.test(message);
|
|
2487
2524
|
}
|
|
2525
|
+
// src/storage/db/index-build.ts
|
|
2526
|
+
/** A cold snapshot cannot arbitrate DDL submitted by independent processes. */
|
|
2527
|
+
async function buildIndex(client, schemaName, indexName, build, logger) {
|
|
2528
|
+
const deadline = performance.now() + 3e4;
|
|
2529
|
+
let attempts = 0;
|
|
2530
|
+
let failure;
|
|
2531
|
+
let state = "not built";
|
|
2532
|
+
let repairFailures = 0;
|
|
2533
|
+
const observe = (db = client) => db.oneOrNone(`SELECT c.oid, i.indrelid AS table_oid, t.relname AS table_name,
|
|
2534
|
+
tn.nspname AS table_schema, i.indisvalid AS valid
|
|
2535
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
2536
|
+
LEFT JOIN pg_index i ON i.indexrelid = c.oid
|
|
2537
|
+
LEFT JOIN pg_class t ON t.oid = i.indrelid
|
|
2538
|
+
LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
|
|
2539
|
+
WHERE n.nspname = $1 AND c.relname = $2`, [schemaName, indexName]);
|
|
2540
|
+
while (true) {
|
|
2541
|
+
const relation = await observe();
|
|
2542
|
+
if (relation?.valid) return true;
|
|
2543
|
+
if (relation && relation.valid === null) throw new IndexBuildError(indexName, schemaName, "name belongs to a non-index relation", failure);
|
|
2544
|
+
if (relation) {
|
|
2545
|
+
if (await client.oneOrNone(`SELECT p.pid FROM pg_stat_progress_create_index p
|
|
2546
|
+
WHERE p.datid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
2547
|
+
AND p.pid <> pg_backend_pid()
|
|
2548
|
+
AND (p.index_relid = $1 OR ((p.index_relid IS NULL OR p.index_relid = 0) AND EXISTS (
|
|
2549
|
+
SELECT 1 FROM pg_locks l WHERE l.pid = p.pid AND l.database = p.datid
|
|
2550
|
+
AND l.locktype = 'relation' AND l.relation = $2 AND l.granted
|
|
2551
|
+
AND l.mode IN ('ShareUpdateExclusiveLock', 'ShareLock')
|
|
2552
|
+
)))
|
|
2553
|
+
LIMIT 1`, [relation.oid, relation.table_oid])) {
|
|
2554
|
+
logger.warn(`Index ${indexName} is being built by another session; skipping`);
|
|
2555
|
+
return false;
|
|
2556
|
+
}
|
|
2557
|
+
if (performance.now() >= deadline && state === "table busy during invalid-index repair") {
|
|
2558
|
+
logger.warn(`Index ${indexName} could not be repaired because its table is busy; skipping`);
|
|
2559
|
+
return false;
|
|
2560
|
+
}
|
|
2561
|
+
if (attempts >= 3 || performance.now() >= deadline) break;
|
|
2562
|
+
state = "abandoned invalid index";
|
|
2563
|
+
attempts++;
|
|
2564
|
+
let lockUnavailable = false;
|
|
2565
|
+
try {
|
|
2566
|
+
const result = await client.tx(async (tx) => {
|
|
2567
|
+
const table = `${(0, pg.escapeIdentifier)(relation.table_schema)}.${(0, pg.escapeIdentifier)(relation.table_name)}`;
|
|
2568
|
+
await tx.none(`SET LOCAL lock_timeout = '100ms'`);
|
|
2569
|
+
try {
|
|
2570
|
+
await tx.none(`LOCK TABLE ${table} IN ACCESS EXCLUSIVE MODE`);
|
|
2571
|
+
} catch (error) {
|
|
2572
|
+
lockUnavailable = typeof error === "object" && error !== null && "code" in error && error.code === "55P03";
|
|
2573
|
+
throw error;
|
|
2574
|
+
}
|
|
2575
|
+
const current = await observe(tx);
|
|
2576
|
+
if (current?.valid) return "valid";
|
|
2577
|
+
const locked = await tx.one("SELECT to_regclass($1)::oid AS oid", [table]);
|
|
2578
|
+
if (!current || current.oid !== relation.oid || current.table_oid !== locked.oid) return "changed";
|
|
2579
|
+
logger.warn(`Dropping invalid index ${indexName} left by an interrupted build; rebuilding it`);
|
|
2580
|
+
await tx.none(`DROP INDEX ${(0, pg.escapeIdentifier)(schemaName)}.${(0, pg.escapeIdentifier)(indexName)}`);
|
|
2581
|
+
return "dropped";
|
|
2582
|
+
});
|
|
2583
|
+
if (result === "valid") return true;
|
|
2584
|
+
if (result === "changed") {
|
|
2585
|
+
attempts--;
|
|
2586
|
+
if (performance.now() >= deadline) break;
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
} catch (error) {
|
|
2590
|
+
attempts--;
|
|
2591
|
+
if (lockUnavailable) {
|
|
2592
|
+
state = "table busy during invalid-index repair";
|
|
2593
|
+
failure ??= error;
|
|
2594
|
+
await (0, timers_promises.setTimeout)(100);
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
if (!isDuplicateRelationError(error) && !isRetryableDeadlock(error) && !(typeof error === "object" && error !== null && "code" in error && error.code === "42P01")) throw error;
|
|
2598
|
+
failure ??= error;
|
|
2599
|
+
if (++repairFailures >= 3 || performance.now() >= deadline) break;
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
} else {
|
|
2603
|
+
if (attempts >= 3 || performance.now() >= deadline) break;
|
|
2604
|
+
attempts++;
|
|
2605
|
+
}
|
|
2606
|
+
try {
|
|
2607
|
+
await build();
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
if (!isDuplicateRelationError(error) && !isRetryableDeadlock(error)) throw error;
|
|
2610
|
+
failure ??= error;
|
|
2611
|
+
const deadlock = isRetryableDeadlock(error);
|
|
2612
|
+
state = deadlock ? "index build deadlock" : "competing index creation";
|
|
2613
|
+
if (deadlock && attempts < 3 && performance.now() < deadline) await backoffAfterDeadlock(attempts);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
throw new IndexBuildError(indexName, schemaName, state, failure);
|
|
2617
|
+
}
|
|
2488
2618
|
// src/storage/db/sanitize-json.ts
|
|
2489
2619
|
const repair = (value) => value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�").replaceAll("\0", "");
|
|
2490
2620
|
function repairJson(value) {
|
|
@@ -2540,9 +2670,10 @@ async function loadSchemaSnapshot(client, schemaName) {
|
|
|
2540
2670
|
AND c.relkind IN ('r', 'p')
|
|
2541
2671
|
AND a.attnum > 0
|
|
2542
2672
|
AND NOT a.attisdropped`, [schema]),
|
|
2543
|
-
client.manyOrNone(`SELECT c.relname AS indexname, i.indisreplident AS is_replica_identity, i.indisprimary AS is_primary, i.indisvalid AS is_valid
|
|
2673
|
+
client.manyOrNone(`SELECT c.relname AS indexname, t.relname AS table_name, i.indisreplident AS is_replica_identity, i.indisprimary AS is_primary, i.indisvalid AS is_valid
|
|
2544
2674
|
FROM pg_catalog.pg_index i
|
|
2545
2675
|
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
|
|
2676
|
+
JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
|
|
2546
2677
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
2547
2678
|
WHERE n.nspname = $1`, [schema])
|
|
2548
2679
|
]);
|
|
@@ -2573,13 +2704,11 @@ async function loadSchemaSnapshot(client, schemaName) {
|
|
|
2573
2704
|
}
|
|
2574
2705
|
const indexes = /* @__PURE__ */ new Set();
|
|
2575
2706
|
const replicaIdentityIndexes = /* @__PURE__ */ new Set();
|
|
2576
|
-
const primaryKeyIndexes = /* @__PURE__ */ new
|
|
2577
|
-
const invalidIndexes = /* @__PURE__ */ new Set();
|
|
2707
|
+
const primaryKeyIndexes = /* @__PURE__ */ new Map();
|
|
2578
2708
|
for (const row of indexRows) {
|
|
2579
2709
|
if (row.is_valid) indexes.add(row.indexname);
|
|
2580
|
-
else invalidIndexes.add(row.indexname);
|
|
2581
2710
|
if (row.is_replica_identity) replicaIdentityIndexes.add(row.indexname);
|
|
2582
|
-
if (row.is_primary) primaryKeyIndexes.
|
|
2711
|
+
if (row.is_primary) primaryKeyIndexes.set(row.indexname.toLowerCase(), row.table_name);
|
|
2583
2712
|
}
|
|
2584
2713
|
return {
|
|
2585
2714
|
schemaName: schema,
|
|
@@ -2589,8 +2718,7 @@ async function loadSchemaSnapshot(client, schemaName) {
|
|
|
2589
2718
|
notNullColumns,
|
|
2590
2719
|
indexes,
|
|
2591
2720
|
replicaIdentityIndexes,
|
|
2592
|
-
primaryKeyIndexes
|
|
2593
|
-
invalidIndexes
|
|
2721
|
+
primaryKeyIndexes
|
|
2594
2722
|
};
|
|
2595
2723
|
}
|
|
2596
2724
|
// src/storage/db/index.ts
|
|
@@ -3502,14 +3630,17 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3502
3630
|
baseName: "mastra_ai_spans_traceid_spanid_pk",
|
|
3503
3631
|
schemaName: (this.schemaName ? schemaNamePrefix(this.schemaName) : "") || void 0
|
|
3504
3632
|
});
|
|
3505
|
-
const
|
|
3633
|
+
const fullTableName = getTableName$6({
|
|
3634
|
+
indexName: _mastra_core_storage.TABLE_SPANS,
|
|
3635
|
+
schemaName: getSchemaName$6(this.schemaName)
|
|
3636
|
+
});
|
|
3506
3637
|
const snapshot = this.schemaSnapshot;
|
|
3507
|
-
if (snapshot) return snapshot.primaryKeyIndexes.
|
|
3508
|
-
return this.spansPrimaryKeyExistsLive(constraintName,
|
|
3638
|
+
if (snapshot) return snapshot.primaryKeyIndexes.get(constraintName.toLowerCase()) === _mastra_core_storage.TABLE_SPANS;
|
|
3639
|
+
return this.spansPrimaryKeyExistsLive(constraintName, fullTableName);
|
|
3509
3640
|
}
|
|
3510
3641
|
/** Live-catalog variant of {@link spansPrimaryKeyExists}, bypassing the snapshot. */
|
|
3511
|
-
async spansPrimaryKeyExistsLive(constraintName,
|
|
3512
|
-
return (await this.client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = lower($1) AND
|
|
3642
|
+
async spansPrimaryKeyExistsLive(constraintName, fullTableName) {
|
|
3643
|
+
return (await this.client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = lower($1) AND contype = 'p' AND conrelid = to_regclass($2)) as exists`, [constraintName, fullTableName]))?.exists ?? false;
|
|
3513
3644
|
}
|
|
3514
3645
|
/**
|
|
3515
3646
|
* Adds the PRIMARY KEY constraint on (traceId, spanId) to the spans table.
|
|
@@ -3524,13 +3655,8 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3524
3655
|
baseName: "mastra_ai_spans_traceid_spanid_pk",
|
|
3525
3656
|
schemaName: (this.schemaName ? schemaNamePrefix(this.schemaName) : "") || void 0
|
|
3526
3657
|
});
|
|
3527
|
-
const schemaFilter = this.schemaName || "public";
|
|
3528
3658
|
try {
|
|
3529
|
-
if (
|
|
3530
|
-
SELECT EXISTS (
|
|
3531
|
-
SELECT 1 FROM pg_constraint WHERE conname = lower($1) AND connamespace = (SELECT oid FROM pg_namespace WHERE nspname = $2)
|
|
3532
|
-
) as exists
|
|
3533
|
-
`, [constraintName, schemaFilter]))?.exists) {
|
|
3659
|
+
if (await this.spansPrimaryKeyExistsLive(constraintName, fullTableName)) {
|
|
3534
3660
|
this.logger?.debug?.(`PRIMARY KEY constraint ${constraintName} already exists on ${fullTableName}`);
|
|
3535
3661
|
return;
|
|
3536
3662
|
}
|
|
@@ -3539,12 +3665,13 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3539
3665
|
ADD CONSTRAINT ${constraintName}
|
|
3540
3666
|
PRIMARY KEY ("traceId", "spanId")
|
|
3541
3667
|
`);
|
|
3542
|
-
this.schemaSnapshot?.primaryKeyIndexes.
|
|
3668
|
+
this.schemaSnapshot?.primaryKeyIndexes.set(constraintName.toLowerCase(), _mastra_core_storage.TABLE_SPANS);
|
|
3543
3669
|
this.logger?.info?.(`Added PRIMARY KEY constraint ${constraintName} to ${fullTableName}`);
|
|
3544
3670
|
} catch (error) {
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3671
|
+
const primaryKeyRace = typeof error === "object" && error !== null && "code" in error && error.code === "42P16";
|
|
3672
|
+
if (isDuplicateRelationError(error) || primaryKeyRace) {
|
|
3673
|
+
if (await this.spansPrimaryKeyExistsLive(constraintName, fullTableName)) {
|
|
3674
|
+
this.schemaSnapshot?.primaryKeyIndexes.set(constraintName.toLowerCase(), _mastra_core_storage.TABLE_SPANS);
|
|
3548
3675
|
this.logger?.debug?.(`PRIMARY KEY constraint ${constraintName} was created by another process`);
|
|
3549
3676
|
return;
|
|
3550
3677
|
}
|
|
@@ -3761,7 +3888,6 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3761
3888
|
});
|
|
3762
3889
|
const snapshot = this.schemaSnapshot;
|
|
3763
3890
|
if (snapshot?.indexes.has(name)) return;
|
|
3764
|
-
if (!await this.prepareIndexBuild(name, schemaName)) return;
|
|
3765
3891
|
const uniqueStr = unique ? "UNIQUE " : "";
|
|
3766
3892
|
const concurrentStr = concurrent ? "CONCURRENTLY " : "";
|
|
3767
3893
|
const methodStr = method !== "btree" ? `USING ${method} ` : "";
|
|
@@ -3779,9 +3905,8 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3779
3905
|
const tablespaceStr = tablespace ? ` TABLESPACE ${tablespace}` : "";
|
|
3780
3906
|
let withStr = "";
|
|
3781
3907
|
if (storage && Object.keys(storage).length > 0) withStr = ` WITH (${Object.entries(storage).map(([key, value]) => `${key} = ${value}`).join(", ")})`;
|
|
3782
|
-
const sql = `CREATE ${uniqueStr}INDEX ${concurrentStr}${`"${(0, _mastra_core_utils.parseSqlIdentifier)(name, "index name")}"`} ON ${fullTableName} ${methodStr}(${columnsStr})${withStr}${tablespaceStr}${whereStr}`;
|
|
3783
|
-
await this.timedIndexBuild(name, sql);
|
|
3784
|
-
snapshot?.indexes.add(name);
|
|
3908
|
+
const sql = `CREATE ${uniqueStr}INDEX ${concurrentStr}IF NOT EXISTS ${`"${(0, _mastra_core_utils.parseSqlIdentifier)(name, "index name")}"`} ON ${fullTableName} ${methodStr}(${columnsStr})${withStr}${tablespaceStr}${whereStr}`;
|
|
3909
|
+
if (await buildIndex(this.client, schemaName, name, () => this.timedIndexBuild(name, sql), this.logger)) snapshot?.indexes.add(name);
|
|
3785
3910
|
} catch (error) {
|
|
3786
3911
|
throw new _mastra_core_error.MastraError({
|
|
3787
3912
|
id: (0, _mastra_core_storage.createStorageErrorId)("PG", "INDEX_CREATE", "FAILED"),
|
|
@@ -3806,36 +3931,7 @@ MIGRATION REQUIRED: Duplicate spans detected in ${duplicateInfo.tableName}\n====
|
|
|
3806
3931
|
async createIndexFromStatement(indexName, sql) {
|
|
3807
3932
|
const snapshot = this.schemaSnapshot;
|
|
3808
3933
|
if (snapshot?.indexes.has(indexName)) return;
|
|
3809
|
-
if (
|
|
3810
|
-
await this.timedIndexBuild(indexName, sql);
|
|
3811
|
-
snapshot?.indexes.add(indexName);
|
|
3812
|
-
}
|
|
3813
|
-
/**
|
|
3814
|
-
* Decides whether `indexName` still needs building. Returns false when a valid
|
|
3815
|
-
* index already exists, or when another session is currently building it.
|
|
3816
|
-
* An invalid index left by an interrupted `CREATE INDEX CONCURRENTLY` is
|
|
3817
|
-
* dropped (concurrently) so the caller rebuilds it — `IF NOT EXISTS` would
|
|
3818
|
-
* otherwise keep the broken index forever.
|
|
3819
|
-
*/
|
|
3820
|
-
async prepareIndexBuild(indexName, schemaName) {
|
|
3821
|
-
const snapshot = this.schemaSnapshot;
|
|
3822
|
-
if (snapshot && !snapshot.invalidIndexes.has(indexName)) return true;
|
|
3823
|
-
const existing = await this.client.oneOrNone(`SELECT i.indisvalid AS is_valid,
|
|
3824
|
-
EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = c.oid) AS building
|
|
3825
|
-
FROM pg_catalog.pg_index i
|
|
3826
|
-
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid
|
|
3827
|
-
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
3828
|
-
WHERE c.relname = $1 AND n.nspname = $2`, [indexName, schemaName]);
|
|
3829
|
-
if (!existing) return true;
|
|
3830
|
-
if (existing.is_valid) return false;
|
|
3831
|
-
if (existing.building) {
|
|
3832
|
-
this.logger.warn(`Index ${indexName} is being built by another session; skipping`);
|
|
3833
|
-
return false;
|
|
3834
|
-
}
|
|
3835
|
-
this.logger.warn(`Dropping invalid index ${indexName} left by an interrupted build; rebuilding it`);
|
|
3836
|
-
const quotedIndexName = `"${(0, _mastra_core_utils.parseSqlIdentifier)(indexName, "index name")}"`;
|
|
3837
|
-
await this.client.none(`DROP INDEX CONCURRENTLY IF EXISTS ${getSchemaName$6(this.schemaName)}.${quotedIndexName}`);
|
|
3838
|
-
return true;
|
|
3934
|
+
if (await buildIndex(this.client, this.schemaName || "public", indexName, () => this.timedIndexBuild(indexName, sql), this.logger)) snapshot?.indexes.add(indexName);
|
|
3839
3935
|
}
|
|
3840
3936
|
async timedIndexBuild(indexName, sql) {
|
|
3841
3937
|
const startedAt = Date.now();
|
|
@@ -16126,6 +16222,51 @@ const SPAN_EVENT_COLUMNS = [
|
|
|
16126
16222
|
type: "double precision",
|
|
16127
16223
|
nullable: true
|
|
16128
16224
|
},
|
|
16225
|
+
{
|
|
16226
|
+
name: "externalParentSpanId",
|
|
16227
|
+
type: "text",
|
|
16228
|
+
nullable: true
|
|
16229
|
+
},
|
|
16230
|
+
{
|
|
16231
|
+
name: "resumedFromSpanId",
|
|
16232
|
+
type: "text",
|
|
16233
|
+
nullable: true
|
|
16234
|
+
},
|
|
16235
|
+
{
|
|
16236
|
+
name: "model",
|
|
16237
|
+
type: "text",
|
|
16238
|
+
nullable: true
|
|
16239
|
+
},
|
|
16240
|
+
{
|
|
16241
|
+
name: "provider",
|
|
16242
|
+
type: "text",
|
|
16243
|
+
nullable: true
|
|
16244
|
+
},
|
|
16245
|
+
{
|
|
16246
|
+
name: "outcome",
|
|
16247
|
+
type: "text",
|
|
16248
|
+
nullable: true
|
|
16249
|
+
},
|
|
16250
|
+
{
|
|
16251
|
+
name: "stopReason",
|
|
16252
|
+
type: "text",
|
|
16253
|
+
nullable: true
|
|
16254
|
+
},
|
|
16255
|
+
{
|
|
16256
|
+
name: "timeToFirstTokenMs",
|
|
16257
|
+
type: "double precision",
|
|
16258
|
+
nullable: true
|
|
16259
|
+
},
|
|
16260
|
+
{
|
|
16261
|
+
name: "errorCount",
|
|
16262
|
+
type: "integer",
|
|
16263
|
+
nullable: true
|
|
16264
|
+
},
|
|
16265
|
+
{
|
|
16266
|
+
name: "stopReasonContext",
|
|
16267
|
+
type: "jsonb",
|
|
16268
|
+
nullable: true
|
|
16269
|
+
},
|
|
16129
16270
|
{
|
|
16130
16271
|
name: "tags",
|
|
16131
16272
|
type: "text[]",
|
|
@@ -16834,6 +16975,26 @@ function allTableDDL(schema, mode) {
|
|
|
16834
16975
|
];
|
|
16835
16976
|
}
|
|
16836
16977
|
/**
|
|
16978
|
+
* Span event columns added after the table first shipped, with their types. All
|
|
16979
|
+
* nullable with no default, so each ALTER is a catalog-only change; rows written
|
|
16980
|
+
* before it read NULL and readers fall back to the payload or attributes.
|
|
16981
|
+
*/
|
|
16982
|
+
const ADDED_SPAN_COLUMNS = [
|
|
16983
|
+
["inputPreview", "text"],
|
|
16984
|
+
["outputPreview", "text"],
|
|
16985
|
+
["errorPreview", "text"],
|
|
16986
|
+
["timeToFirstChunkMs", "double precision"],
|
|
16987
|
+
["externalParentSpanId", "text"],
|
|
16988
|
+
["resumedFromSpanId", "text"],
|
|
16989
|
+
["model", "text"],
|
|
16990
|
+
["provider", "text"],
|
|
16991
|
+
["outcome", "text"],
|
|
16992
|
+
["stopReason", "text"],
|
|
16993
|
+
["timeToFirstTokenMs", "double precision"],
|
|
16994
|
+
["errorCount", "integer"],
|
|
16995
|
+
["stopReasonContext", "jsonb"]
|
|
16996
|
+
];
|
|
16997
|
+
/**
|
|
16837
16998
|
* Additive column migrations for tables created by an older version of this
|
|
16838
16999
|
* schema. `CREATE TABLE IF NOT EXISTS` is a no-op on an existing table, so new
|
|
16839
17000
|
* columns have to be added out of band.
|
|
@@ -16850,20 +17011,11 @@ function additiveColumns(schema) {
|
|
|
16850
17011
|
column: "isPending",
|
|
16851
17012
|
ddl: `ALTER TABLE ${qualifiedTable(schema, TABLE_SPAN_EVENTS)} ADD COLUMN IF NOT EXISTS "isPending" boolean NOT NULL DEFAULT false`
|
|
16852
17013
|
},
|
|
16853
|
-
...[
|
|
16854
|
-
"inputPreview",
|
|
16855
|
-
"outputPreview",
|
|
16856
|
-
"errorPreview"
|
|
16857
|
-
].map((column) => ({
|
|
17014
|
+
...ADDED_SPAN_COLUMNS.map(([column, type]) => ({
|
|
16858
17015
|
table: TABLE_SPAN_EVENTS,
|
|
16859
17016
|
column,
|
|
16860
|
-
ddl: `ALTER TABLE ${qualifiedTable(schema, TABLE_SPAN_EVENTS)} ADD COLUMN IF NOT EXISTS "${column}"
|
|
17017
|
+
ddl: `ALTER TABLE ${qualifiedTable(schema, TABLE_SPAN_EVENTS)} ADD COLUMN IF NOT EXISTS "${column}" ${type}`
|
|
16861
17018
|
})),
|
|
16862
|
-
{
|
|
16863
|
-
table: TABLE_SPAN_EVENTS,
|
|
16864
|
-
column: "timeToFirstChunkMs",
|
|
16865
|
-
ddl: `ALTER TABLE ${qualifiedTable(schema, TABLE_SPAN_EVENTS)} ADD COLUMN IF NOT EXISTS "timeToFirstChunkMs" double precision`
|
|
16866
|
-
},
|
|
16867
17019
|
{
|
|
16868
17020
|
table: TABLE_FEEDBACK_EVENTS,
|
|
16869
17021
|
column: "reviewStatus",
|
|
@@ -17266,6 +17418,203 @@ function parseUpdateFeedbackReviewStatusArgs(args) {
|
|
|
17266
17418
|
reviewStatus: args.reviewStatus
|
|
17267
17419
|
};
|
|
17268
17420
|
}
|
|
17421
|
+
// src/storage/domains/observability/v-next/partitioning.ts
|
|
17422
|
+
const DEFAULT_FUTURE_DAYS = 14;
|
|
17423
|
+
async function detectTimescale(client) {
|
|
17424
|
+
const row = await client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') AS "exists"`);
|
|
17425
|
+
return Boolean(row?.exists);
|
|
17426
|
+
}
|
|
17427
|
+
async function detectPartman(client) {
|
|
17428
|
+
const row = await client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') AS "exists"`);
|
|
17429
|
+
return Boolean(row?.exists);
|
|
17430
|
+
}
|
|
17431
|
+
async function getPartmanVersion(client) {
|
|
17432
|
+
return (await client.oneOrNone(`SELECT extversion FROM pg_extension WHERE extname = 'pg_partman'`))?.extversion ?? null;
|
|
17433
|
+
}
|
|
17434
|
+
function getPartmanMajor(version) {
|
|
17435
|
+
if (!version) return null;
|
|
17436
|
+
const match = /^(\d+)/.exec(version);
|
|
17437
|
+
return match ? Number(match[1]) : null;
|
|
17438
|
+
}
|
|
17439
|
+
async function resolveMode(client, options = {}) {
|
|
17440
|
+
if (options.mode && options.mode !== "auto") return options.mode;
|
|
17441
|
+
if (await detectTimescale(client)) return "timescale";
|
|
17442
|
+
if (await detectPartman(client)) return "partman";
|
|
17443
|
+
return "native";
|
|
17444
|
+
}
|
|
17445
|
+
function dayBounds(d) {
|
|
17446
|
+
const year = d.getUTCFullYear();
|
|
17447
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
17448
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
17449
|
+
const start = `${year}-${month}-${day} 00:00:00+00`;
|
|
17450
|
+
const next = new Date(Date.UTC(year, d.getUTCMonth(), d.getUTCDate() + 1));
|
|
17451
|
+
return {
|
|
17452
|
+
start,
|
|
17453
|
+
end: `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, "0")}-${String(next.getUTCDate()).padStart(2, "0")} 00:00:00+00`,
|
|
17454
|
+
suffix: `${year}${month}${day}`
|
|
17455
|
+
};
|
|
17456
|
+
}
|
|
17457
|
+
function partitionName(table, suffix) {
|
|
17458
|
+
return `${table}_p${suffix}`;
|
|
17459
|
+
}
|
|
17460
|
+
/**
|
|
17461
|
+
* Creates daily partition tables in [today - 1 day, today + futureDays].
|
|
17462
|
+
* Idempotent: uses CREATE TABLE IF NOT EXISTS.
|
|
17463
|
+
*/
|
|
17464
|
+
async function ensureNativePartitions(client, schema, options = {}) {
|
|
17465
|
+
const windowDays = partitionWindow(options);
|
|
17466
|
+
for (const table of ALL_SIGNAL_TABLES) await ensureDayPartitions(client, schema, table, windowDays);
|
|
17467
|
+
}
|
|
17468
|
+
/** The UTC days init() pre-creates: yesterday (optional) through today + futureDays. */
|
|
17469
|
+
function partitionWindow(options = {}, nowMs = Date.now()) {
|
|
17470
|
+
const futureDays = options.futureDays ?? DEFAULT_FUTURE_DAYS;
|
|
17471
|
+
const start = options.includeYesterday ?? true ? -1 : 0;
|
|
17472
|
+
const days = [];
|
|
17473
|
+
for (let i = start; i <= futureDays; i++) days.push(new Date(nowMs + i * 864e5));
|
|
17474
|
+
return days;
|
|
17475
|
+
}
|
|
17476
|
+
/**
|
|
17477
|
+
* Keeps only the days inside the current partition window. The write path
|
|
17478
|
+
* creates partitions on demand within that window only, so a row with a
|
|
17479
|
+
* client-supplied timestamp far in the past or future cannot make the
|
|
17480
|
+
* database grow a table per day it names.
|
|
17481
|
+
*/
|
|
17482
|
+
function withinPartitionWindow(days, options = {}) {
|
|
17483
|
+
const allowed = new Set(partitionWindow(options).map((d) => dayBounds(d).suffix));
|
|
17484
|
+
return days.filter((d) => allowed.has(dayBounds(d).suffix));
|
|
17485
|
+
}
|
|
17486
|
+
/**
|
|
17487
|
+
* Creates and attaches a partition of `schema.table` for each UTC day in
|
|
17488
|
+
* `days` that has none. Each day is a `CREATE TABLE (LIKE parent)` followed by
|
|
17489
|
+
* `ATTACH PARTITION`, in one transaction, so a failed attach leaves no
|
|
17490
|
+
* detached child behind. When either step fails, the catalog is read again: a
|
|
17491
|
+
* day that a concurrent caller attached meanwhile counts as done, and any
|
|
17492
|
+
* other failure is rethrown.
|
|
17493
|
+
*/
|
|
17494
|
+
async function ensureDayPartitions(client, schema, table, days) {
|
|
17495
|
+
const parent = qualifiedTable(schema, table);
|
|
17496
|
+
const attached = await attachedPartitionNames(client, schema, table);
|
|
17497
|
+
for (const d of days) {
|
|
17498
|
+
const { start: partStart, end: partEnd, suffix } = dayBounds(d);
|
|
17499
|
+
const childName = partitionName(table, suffix);
|
|
17500
|
+
if (attached.has(childName)) continue;
|
|
17501
|
+
const child = qualifiedName(schema, childName);
|
|
17502
|
+
try {
|
|
17503
|
+
await client.tx(async (t) => {
|
|
17504
|
+
await t.none(`CREATE TABLE IF NOT EXISTS ${child} (LIKE ${parent} INCLUDING ALL)`);
|
|
17505
|
+
await t.none(`ALTER TABLE ${parent} ATTACH PARTITION ${child}
|
|
17506
|
+
FOR VALUES FROM ('${partStart}') TO ('${partEnd}')`);
|
|
17507
|
+
});
|
|
17508
|
+
} catch (error) {
|
|
17509
|
+
if (!(await attachedPartitionNames(client, schema, table)).has(childName)) throw error;
|
|
17510
|
+
}
|
|
17511
|
+
}
|
|
17512
|
+
}
|
|
17513
|
+
/** Names of the children attached to `schema.table`, read from the catalog so no search_path can shadow it. */
|
|
17514
|
+
async function attachedPartitionNames(client, schema, table) {
|
|
17515
|
+
const rows = await client.manyOrNone(`SELECT c.relname
|
|
17516
|
+
FROM pg_catalog.pg_inherits i
|
|
17517
|
+
JOIN pg_catalog.pg_class c ON c.oid = i.inhrelid
|
|
17518
|
+
WHERE i.inhparent = (
|
|
17519
|
+
SELECT p.oid
|
|
17520
|
+
FROM pg_catalog.pg_class p
|
|
17521
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.relnamespace
|
|
17522
|
+
WHERE n.nspname = $1 AND p.relname = $2
|
|
17523
|
+
)`, [schema, table]);
|
|
17524
|
+
return new Set(rows.map((r) => r.relname));
|
|
17525
|
+
}
|
|
17526
|
+
/**
|
|
17527
|
+
* `23514` raised by `ExecFindPartition`: the row's day has no attached
|
|
17528
|
+
* partition. The message is matched as a fallback only, because Postgres
|
|
17529
|
+
* translates it when `lc_messages` is not English.
|
|
17530
|
+
*/
|
|
17531
|
+
function isMissingPartitionError(error) {
|
|
17532
|
+
const { code, routine, message = "" } = error ?? {};
|
|
17533
|
+
return code === "23514" && (routine === "ExecFindPartition" || /no partition of relation/i.test(message));
|
|
17534
|
+
}
|
|
17535
|
+
/**
|
|
17536
|
+
* Calls `create_hypertable()` for each signal table.
|
|
17537
|
+
*
|
|
17538
|
+
* Postgres declarative partitioning and Timescale hypertables are mutually
|
|
17539
|
+
* exclusive, so the base DDL must have been generated with TableDDLMode
|
|
17540
|
+
* 'timescale' (no `PARTITION BY` clause). `if_not_exists` makes this
|
|
17541
|
+
* idempotent across re-inits.
|
|
17542
|
+
*/
|
|
17543
|
+
async function ensureTimescaleHypertables(client, schema) {
|
|
17544
|
+
for (const table of ALL_SIGNAL_TABLES) {
|
|
17545
|
+
const tableExpr = qualifiedTable(schema, table);
|
|
17546
|
+
const timeColumn = SIGNAL_TIME_COLUMN[table].replace(/'/g, "''");
|
|
17547
|
+
await client.none(`SELECT create_hypertable($1::regclass, '${timeColumn}', chunk_time_interval => INTERVAL '1 day', if_not_exists => true)`, [tableExpr]);
|
|
17548
|
+
}
|
|
17549
|
+
}
|
|
17550
|
+
/**
|
|
17551
|
+
* Registers each signal table with pg_partman as a daily-interval parent and
|
|
17552
|
+
* lets partman create the child partitions.
|
|
17553
|
+
*
|
|
17554
|
+
* Retention is intentionally NOT configured here — it stays opt-in via the
|
|
17555
|
+
* future Mastra CLI surface.
|
|
17556
|
+
*
|
|
17557
|
+
* Prefers the pg_partman 5.x+ declarative API (`p_type := 'range'`) and keeps
|
|
17558
|
+
* a 4.x fallback (`p_type := 'native'`) for older installs. That lets the
|
|
17559
|
+
* adapter run against current arm64-friendly images without breaking existing
|
|
17560
|
+
* 4.x deployments.
|
|
17561
|
+
*/
|
|
17562
|
+
async function partmanParentRegistered(client, schema, table) {
|
|
17563
|
+
const row = await client.oneOrNone(`SELECT EXISTS (
|
|
17564
|
+
SELECT 1 FROM partman.part_config
|
|
17565
|
+
WHERE parent_table = format('%I.%I', $1::text, $2::text)
|
|
17566
|
+
) AS "exists"`, [schema, table]);
|
|
17567
|
+
return Boolean(row?.exists);
|
|
17568
|
+
}
|
|
17569
|
+
async function ensurePartmanHypertables(client, schema) {
|
|
17570
|
+
const partmanMajor = getPartmanMajor(await getPartmanVersion(client));
|
|
17571
|
+
for (const table of ALL_SIGNAL_TABLES) {
|
|
17572
|
+
if (await partmanParentRegistered(client, schema, table)) continue;
|
|
17573
|
+
for (let attempt = 0; attempt < 3; attempt++) try {
|
|
17574
|
+
if (partmanMajor != null && partmanMajor >= 5) await client.none(`SELECT partman.create_parent(
|
|
17575
|
+
p_parent_table := format('%I.%I', $1::text, $2::text),
|
|
17576
|
+
p_control := $3,
|
|
17577
|
+
p_interval := '1 day',
|
|
17578
|
+
p_type := 'range'
|
|
17579
|
+
)`, [
|
|
17580
|
+
schema,
|
|
17581
|
+
table,
|
|
17582
|
+
SIGNAL_TIME_COLUMN[table]
|
|
17583
|
+
]);
|
|
17584
|
+
else await client.none(`SELECT partman.create_parent(
|
|
17585
|
+
p_parent_table := format('%I.%I', $1::text, $2::text),
|
|
17586
|
+
p_control := $3,
|
|
17587
|
+
p_type := 'native',
|
|
17588
|
+
p_interval := '1 day'
|
|
17589
|
+
)`, [
|
|
17590
|
+
schema,
|
|
17591
|
+
table,
|
|
17592
|
+
SIGNAL_TIME_COLUMN[table]
|
|
17593
|
+
]);
|
|
17594
|
+
break;
|
|
17595
|
+
} catch (error) {
|
|
17596
|
+
const message = error?.message ?? "";
|
|
17597
|
+
if (error?.code === "23505" || /already managed by pg_partman/i.test(message) || /relation .* already exists/i.test(message)) break;
|
|
17598
|
+
if (!/deadlock detected/i.test(message)) throw error;
|
|
17599
|
+
if (await partmanParentRegistered(client, schema, table)) break;
|
|
17600
|
+
if (attempt === 2) throw error;
|
|
17601
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
17602
|
+
}
|
|
17603
|
+
}
|
|
17604
|
+
}
|
|
17605
|
+
async function setupPartitioning(client, schema, options = {}) {
|
|
17606
|
+
switch (await resolveMode(client, options)) {
|
|
17607
|
+
case "timescale":
|
|
17608
|
+
await ensureTimescaleHypertables(client, schema);
|
|
17609
|
+
return "timescale";
|
|
17610
|
+
case "partman":
|
|
17611
|
+
await ensurePartmanHypertables(client, schema);
|
|
17612
|
+
return "partman";
|
|
17613
|
+
case "native":
|
|
17614
|
+
await ensureNativePartitions(client, schema, options);
|
|
17615
|
+
return "native";
|
|
17616
|
+
}
|
|
17617
|
+
}
|
|
17269
17618
|
// src/storage/domains/observability/v-next/sql.ts
|
|
17270
17619
|
/**
|
|
17271
17620
|
* SQL helpers for the v-next Postgres observability domain.
|
|
@@ -17346,6 +17695,54 @@ ${onConflict}`,
|
|
|
17346
17695
|
values
|
|
17347
17696
|
};
|
|
17348
17697
|
}
|
|
17698
|
+
/**
|
|
17699
|
+
* Insert into a signal table. In native partition mode, a row whose day is
|
|
17700
|
+
* inside the partition window but has no partition yet gets one created, then
|
|
17701
|
+
* the insert runs once more. TimescaleDB and pg_partman own their partitions,
|
|
17702
|
+
* so in those modes the original error propagates untouched. The mode is the
|
|
17703
|
+
* one the store resolved; a store that never ran init() (`disableInit`)
|
|
17704
|
+
* detects it here, after the failed insert, so the hot path stays one query.
|
|
17705
|
+
*/
|
|
17706
|
+
async function insertRows(client, schema, table, records, partitioning, onConflict) {
|
|
17707
|
+
const insert = buildInsert(schema, table, records, onConflict);
|
|
17708
|
+
if (!insert) return;
|
|
17709
|
+
try {
|
|
17710
|
+
await client.query(insert.text, insert.values);
|
|
17711
|
+
} catch (error) {
|
|
17712
|
+
if (!isMissingPartitionError(error) || await resolveMode(client, partitioning) !== "native") throw error;
|
|
17713
|
+
const days = withinPartitionWindow(recordDays(records, SIGNAL_TIME_COLUMN[table]), partitioning);
|
|
17714
|
+
if (days.length === 0) throw error;
|
|
17715
|
+
for (const day of days) await ensureDayPartitions(client, schema, table, [day]).catch((cause) => {
|
|
17716
|
+
if (!isInsufficientPrivilegeError(cause)) throw cause;
|
|
17717
|
+
throw new Error(partitionCreateFailure({
|
|
17718
|
+
table,
|
|
17719
|
+
day,
|
|
17720
|
+
cause
|
|
17721
|
+
}), { cause });
|
|
17722
|
+
});
|
|
17723
|
+
await client.query(insert.text, insert.values);
|
|
17724
|
+
}
|
|
17725
|
+
}
|
|
17726
|
+
/** `42501`: the role may not create the child, or does not own the parent it must attach to. */
|
|
17727
|
+
function isInsufficientPrivilegeError(error) {
|
|
17728
|
+
return error?.code === "42501";
|
|
17729
|
+
}
|
|
17730
|
+
function partitionCreateFailure({ table, day, cause }) {
|
|
17731
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
17732
|
+
return `Could not create the ${table} partition for ${day.toISOString().slice(0, 10)} (UTC): ${reason}. Create the partitions with a role that can create tables in the schema and owns the observability tables, for example by calling ensurePartitions() on a schedule.`;
|
|
17733
|
+
}
|
|
17734
|
+
function recordDays(records, timeColumn) {
|
|
17735
|
+
const days = /* @__PURE__ */ new Map();
|
|
17736
|
+
for (const record of records) {
|
|
17737
|
+
const value = record[timeColumn];
|
|
17738
|
+
if (value == null) continue;
|
|
17739
|
+
const d = new Date(value);
|
|
17740
|
+
if (isNaN(d.getTime())) continue;
|
|
17741
|
+
const day = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
17742
|
+
days.set(day.toISOString(), day);
|
|
17743
|
+
}
|
|
17744
|
+
return [...days.values()];
|
|
17745
|
+
}
|
|
17349
17746
|
/** Primary key of the span table; also the conflict target for span upserts. */
|
|
17350
17747
|
const SPAN_KEY_COLUMNS = [
|
|
17351
17748
|
"traceId",
|
|
@@ -17416,6 +17813,11 @@ const PROMOTED_KEYS = /* @__PURE__ */ new Set([
|
|
|
17416
17813
|
"executionSource",
|
|
17417
17814
|
"serviceName"
|
|
17418
17815
|
]);
|
|
17816
|
+
function nullableNumber$1(value) {
|
|
17817
|
+
if (value == null || value === "") return null;
|
|
17818
|
+
const n = Number(value);
|
|
17819
|
+
return Number.isNaN(n) ? null : n;
|
|
17820
|
+
}
|
|
17419
17821
|
function nullableString(value) {
|
|
17420
17822
|
if (typeof value === "string") return value === "" ? null : value;
|
|
17421
17823
|
if (value == null) return null;
|
|
@@ -17577,6 +17979,15 @@ function spanRecordToRow(span) {
|
|
|
17577
17979
|
startedAt: toIsoOrDate(span.startedAt),
|
|
17578
17980
|
endedAt: toIsoOrDate(endedAt),
|
|
17579
17981
|
timeToFirstChunkMs: span.timeToFirstChunkMs ?? null,
|
|
17982
|
+
externalParentSpanId: span.externalParentSpanId ?? null,
|
|
17983
|
+
resumedFromSpanId: span.resumedFromSpanId ?? null,
|
|
17984
|
+
model: span.model ?? null,
|
|
17985
|
+
provider: span.provider ?? null,
|
|
17986
|
+
outcome: span.outcome ?? null,
|
|
17987
|
+
stopReason: span.stopReason ?? null,
|
|
17988
|
+
stopReasonContext: jsonField(span.stopReasonContext),
|
|
17989
|
+
timeToFirstTokenMs: span.timeToFirstTokenMs ?? null,
|
|
17990
|
+
errorCount: span.errorCount ?? null,
|
|
17580
17991
|
tags: normalizeTags(span.tags),
|
|
17581
17992
|
metadataSearch: buildMetadataSearch(metadata),
|
|
17582
17993
|
metadataRaw: jsonField(metadata),
|
|
@@ -17606,6 +18017,7 @@ function rowToSpanRecord(row) {
|
|
|
17606
18017
|
const startedAt = toDate(row.startedAt);
|
|
17607
18018
|
const endedAt = spanRowEndedAt(row, startedAt);
|
|
17608
18019
|
const error = parsedJson(row.error);
|
|
18020
|
+
const attributes = parsedJson(row.attributes);
|
|
17609
18021
|
const { executionSource, ...ctx } = rowToCommonContext(row);
|
|
17610
18022
|
return {
|
|
17611
18023
|
...ctx,
|
|
@@ -17618,10 +18030,19 @@ function rowToSpanRecord(row) {
|
|
|
17618
18030
|
isEvent: Boolean(row.isEvent),
|
|
17619
18031
|
startedAt,
|
|
17620
18032
|
endedAt,
|
|
18033
|
+
externalParentSpanId: nullableString(row.externalParentSpanId),
|
|
18034
|
+
resumedFromSpanId: nullableString(row.resumedFromSpanId),
|
|
18035
|
+
model: nullableString(row.model) ?? (0, _mastra_core_storage.getStringAttribute)(attributes, "model"),
|
|
18036
|
+
provider: nullableString(row.provider) ?? (0, _mastra_core_storage.getStringAttribute)(attributes, "provider"),
|
|
18037
|
+
outcome: nullableString(row.outcome),
|
|
18038
|
+
stopReason: nullableString(row.stopReason),
|
|
18039
|
+
stopReasonContext: parsedJson(row.stopReasonContext) ?? null,
|
|
18040
|
+
timeToFirstTokenMs: nullableNumber$1(row.timeToFirstTokenMs),
|
|
18041
|
+
errorCount: nullableNumber$1(row.errorCount),
|
|
17621
18042
|
tags: normalizeTags(row.tags),
|
|
17622
18043
|
metadata: parsedJson(row.metadataRaw) ?? void 0,
|
|
17623
18044
|
scope: parsedJson(row.scope) ?? void 0,
|
|
17624
|
-
attributes:
|
|
18045
|
+
attributes: attributes ?? void 0,
|
|
17625
18046
|
links: parsedJson(row.links) ?? void 0,
|
|
17626
18047
|
input: parsedJson(row.input) ?? void 0,
|
|
17627
18048
|
output: parsedJson(row.output) ?? void 0,
|
|
@@ -18232,16 +18653,14 @@ function pushFeedbackIdentity(acc, feedbackType, feedbackSource) {
|
|
|
18232
18653
|
}
|
|
18233
18654
|
acc.conditions.push(`"valueNumber" IS NOT NULL`);
|
|
18234
18655
|
}
|
|
18235
|
-
async function createFeedback(client, schema, args) {
|
|
18656
|
+
async function createFeedback(client, schema, args, partitioning) {
|
|
18236
18657
|
const row = feedbackRecordToRow(args.feedback);
|
|
18237
|
-
|
|
18238
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
18658
|
+
await insertRows(client, schema, TABLE_FEEDBACK_EVENTS, [row], partitioning, FEEDBACK_UPSERT_CLAUSE);
|
|
18239
18659
|
}
|
|
18240
|
-
async function batchCreateFeedback(client, schema, args) {
|
|
18660
|
+
async function batchCreateFeedback(client, schema, args, partitioning) {
|
|
18241
18661
|
if (args.feedbacks.length === 0) return;
|
|
18242
18662
|
const rows = [...new Map(args.feedbacks.map((feedback) => [feedback.feedbackId, feedback])).values()].map(feedbackRecordToRow);
|
|
18243
|
-
|
|
18244
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
18663
|
+
await insertRows(client, schema, TABLE_FEEDBACK_EVENTS, rows, partitioning, FEEDBACK_UPSERT_CLAUSE);
|
|
18245
18664
|
}
|
|
18246
18665
|
async function updateFeedbackReviewStatus(client, schema, args) {
|
|
18247
18666
|
const { feedbackId, reviewStatus } = parseUpdateFeedbackReviewStatusArgs(args);
|
|
@@ -18439,11 +18858,10 @@ function applyLogFilters(acc, filters) {
|
|
|
18439
18858
|
applyCommonFilters(acc, filters);
|
|
18440
18859
|
applySingleOrArrayFilter(acc, "level", filters?.level);
|
|
18441
18860
|
}
|
|
18442
|
-
async function batchCreateLogs(client, schema, args) {
|
|
18861
|
+
async function batchCreateLogs(client, schema, args, partitioning) {
|
|
18443
18862
|
if (args.logs.length === 0) return;
|
|
18444
18863
|
const rows = args.logs.map(logRecordToRow);
|
|
18445
|
-
|
|
18446
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
18864
|
+
await insertRows(client, schema, TABLE_LOG_EVENTS, rows, partitioning);
|
|
18447
18865
|
}
|
|
18448
18866
|
async function listLogs(client, schema, args) {
|
|
18449
18867
|
const { mode, filters, pagination, orderBy, after, limit } = _mastra_core_storage.listLogsArgsSchema.parse(args);
|
|
@@ -18514,11 +18932,10 @@ function pushMetricNameFilter(acc, names) {
|
|
|
18514
18932
|
acc.conditions.push(`"name" IN (${placeholders})`);
|
|
18515
18933
|
acc.params.push(...names);
|
|
18516
18934
|
}
|
|
18517
|
-
async function batchCreateMetrics(client, schema, args) {
|
|
18935
|
+
async function batchCreateMetrics(client, schema, args, partitioning) {
|
|
18518
18936
|
if (args.metrics.length === 0) return;
|
|
18519
18937
|
const rows = args.metrics.map(metricRecordToRow);
|
|
18520
|
-
|
|
18521
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
18938
|
+
await insertRows(client, schema, TABLE_METRIC_EVENTS, rows, partitioning);
|
|
18522
18939
|
}
|
|
18523
18940
|
async function listMetrics(client, schema, args) {
|
|
18524
18941
|
const { mode, filters, pagination, orderBy, after, limit } = _mastra_core_storage.listMetricsArgsSchema.parse(args);
|
|
@@ -18720,165 +19137,6 @@ async function getMetricPercentiles(client, schema, args) {
|
|
|
18720
19137
|
`;
|
|
18721
19138
|
return { series: percentileSeriesFromRows(await client.manyOrNone(sql, acc.params), args.percentiles) };
|
|
18722
19139
|
}
|
|
18723
|
-
// src/storage/domains/observability/v-next/partitioning.ts
|
|
18724
|
-
const DEFAULT_FUTURE_DAYS = 14;
|
|
18725
|
-
async function detectTimescale(client) {
|
|
18726
|
-
const row = await client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') AS "exists"`);
|
|
18727
|
-
return Boolean(row?.exists);
|
|
18728
|
-
}
|
|
18729
|
-
async function detectPartman(client) {
|
|
18730
|
-
const row = await client.oneOrNone(`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') AS "exists"`);
|
|
18731
|
-
return Boolean(row?.exists);
|
|
18732
|
-
}
|
|
18733
|
-
async function getPartmanVersion(client) {
|
|
18734
|
-
return (await client.oneOrNone(`SELECT extversion FROM pg_extension WHERE extname = 'pg_partman'`))?.extversion ?? null;
|
|
18735
|
-
}
|
|
18736
|
-
function getPartmanMajor(version) {
|
|
18737
|
-
if (!version) return null;
|
|
18738
|
-
const match = /^(\d+)/.exec(version);
|
|
18739
|
-
return match ? Number(match[1]) : null;
|
|
18740
|
-
}
|
|
18741
|
-
async function resolveMode(client, options = {}) {
|
|
18742
|
-
if (options.mode && options.mode !== "auto") return options.mode;
|
|
18743
|
-
if (await detectTimescale(client)) return "timescale";
|
|
18744
|
-
if (await detectPartman(client)) return "partman";
|
|
18745
|
-
return "native";
|
|
18746
|
-
}
|
|
18747
|
-
function dayBounds(d) {
|
|
18748
|
-
const year = d.getUTCFullYear();
|
|
18749
|
-
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
18750
|
-
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
18751
|
-
const start = `${year}-${month}-${day} 00:00:00+00`;
|
|
18752
|
-
const next = new Date(Date.UTC(year, d.getUTCMonth(), d.getUTCDate() + 1));
|
|
18753
|
-
return {
|
|
18754
|
-
start,
|
|
18755
|
-
end: `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, "0")}-${String(next.getUTCDate()).padStart(2, "0")} 00:00:00+00`,
|
|
18756
|
-
suffix: `${year}${month}${day}`
|
|
18757
|
-
};
|
|
18758
|
-
}
|
|
18759
|
-
function partitionName(table, suffix) {
|
|
18760
|
-
return `${table}_p${suffix}`;
|
|
18761
|
-
}
|
|
18762
|
-
/**
|
|
18763
|
-
* Creates daily partition tables in [today - 1 day, today + futureDays].
|
|
18764
|
-
* Idempotent: uses CREATE TABLE IF NOT EXISTS.
|
|
18765
|
-
*/
|
|
18766
|
-
async function ensureNativePartitions(client, schema, options = {}) {
|
|
18767
|
-
const futureDays = options.futureDays ?? DEFAULT_FUTURE_DAYS;
|
|
18768
|
-
const start = options.includeYesterday ?? true ? -1 : 0;
|
|
18769
|
-
const baseNowMs = Date.now();
|
|
18770
|
-
for (const table of ALL_SIGNAL_TABLES) for (let i = start; i <= futureDays; i++) {
|
|
18771
|
-
const { start: partStart, end: partEnd, suffix } = dayBounds(new Date(baseNowMs + i * 864e5));
|
|
18772
|
-
const child = qualifiedName(schema, partitionName(table, suffix));
|
|
18773
|
-
const parent = qualifiedTable(schema, table);
|
|
18774
|
-
try {
|
|
18775
|
-
await client.none(`CREATE TABLE IF NOT EXISTS ${child} (LIKE ${parent} INCLUDING ALL)`);
|
|
18776
|
-
} catch (error) {
|
|
18777
|
-
if (!isDuplicateRelationError(error)) throw error;
|
|
18778
|
-
}
|
|
18779
|
-
try {
|
|
18780
|
-
await client.none(`ALTER TABLE ${parent} ATTACH PARTITION ${child}
|
|
18781
|
-
FOR VALUES FROM ('${partStart}') TO ('${partEnd}')`);
|
|
18782
|
-
} catch (error) {
|
|
18783
|
-
if (!isAlreadyAttachedError(error)) throw error;
|
|
18784
|
-
}
|
|
18785
|
-
}
|
|
18786
|
-
}
|
|
18787
|
-
/**
|
|
18788
|
-
* `ALTER TABLE ATTACH PARTITION` fails with `42P17 invalid_object_definition`
|
|
18789
|
-
* when the target table is already a partition of *any* parent (including
|
|
18790
|
-
* the one we want). On older PG it can also surface as 42710 or a generic
|
|
18791
|
-
* message. We err on the side of treating any "already a partition" wording
|
|
18792
|
-
* as success so concurrent inits converge cleanly.
|
|
18793
|
-
*/
|
|
18794
|
-
function isAlreadyAttachedError(error) {
|
|
18795
|
-
const code = error?.code;
|
|
18796
|
-
const message = error?.message ?? "";
|
|
18797
|
-
if (code === "42P17" || code === "42710") return /is already a partition/i.test(message) || /already a partition/i.test(message);
|
|
18798
|
-
return /is already a partition/i.test(message);
|
|
18799
|
-
}
|
|
18800
|
-
/**
|
|
18801
|
-
* Calls `create_hypertable()` for each signal table.
|
|
18802
|
-
*
|
|
18803
|
-
* Postgres declarative partitioning and Timescale hypertables are mutually
|
|
18804
|
-
* exclusive, so the base DDL must have been generated with TableDDLMode
|
|
18805
|
-
* 'timescale' (no `PARTITION BY` clause). `if_not_exists` makes this
|
|
18806
|
-
* idempotent across re-inits.
|
|
18807
|
-
*/
|
|
18808
|
-
async function ensureTimescaleHypertables(client, schema) {
|
|
18809
|
-
for (const table of ALL_SIGNAL_TABLES) {
|
|
18810
|
-
const tableExpr = qualifiedTable(schema, table);
|
|
18811
|
-
const timeColumn = SIGNAL_TIME_COLUMN[table].replace(/'/g, "''");
|
|
18812
|
-
await client.none(`SELECT create_hypertable($1::regclass, '${timeColumn}', chunk_time_interval => INTERVAL '1 day', if_not_exists => true)`, [tableExpr]);
|
|
18813
|
-
}
|
|
18814
|
-
}
|
|
18815
|
-
/**
|
|
18816
|
-
* Registers each signal table with pg_partman as a daily-interval parent and
|
|
18817
|
-
* lets partman create the child partitions.
|
|
18818
|
-
*
|
|
18819
|
-
* Retention is intentionally NOT configured here — it stays opt-in via the
|
|
18820
|
-
* future Mastra CLI surface.
|
|
18821
|
-
*
|
|
18822
|
-
* Prefers the pg_partman 5.x+ declarative API (`p_type := 'range'`) and keeps
|
|
18823
|
-
* a 4.x fallback (`p_type := 'native'`) for older installs. That lets the
|
|
18824
|
-
* adapter run against current arm64-friendly images without breaking existing
|
|
18825
|
-
* 4.x deployments.
|
|
18826
|
-
*/
|
|
18827
|
-
async function ensurePartmanHypertables(client, schema) {
|
|
18828
|
-
const partmanMajor = getPartmanMajor(await getPartmanVersion(client));
|
|
18829
|
-
for (const table of ALL_SIGNAL_TABLES) {
|
|
18830
|
-
if ((await client.oneOrNone(`SELECT EXISTS (
|
|
18831
|
-
SELECT 1 FROM partman.part_config
|
|
18832
|
-
WHERE parent_table = format('%I.%I', $1::text, $2::text)
|
|
18833
|
-
) AS "exists"`, [schema, table]))?.exists) continue;
|
|
18834
|
-
for (let attempt = 0; attempt < 3; attempt++) try {
|
|
18835
|
-
if (partmanMajor != null && partmanMajor >= 5) await client.none(`SELECT partman.create_parent(
|
|
18836
|
-
p_parent_table := format('%I.%I', $1::text, $2::text),
|
|
18837
|
-
p_control := $3,
|
|
18838
|
-
p_interval := '1 day',
|
|
18839
|
-
p_type := 'range'
|
|
18840
|
-
)`, [
|
|
18841
|
-
schema,
|
|
18842
|
-
table,
|
|
18843
|
-
SIGNAL_TIME_COLUMN[table]
|
|
18844
|
-
]);
|
|
18845
|
-
else await client.none(`SELECT partman.create_parent(
|
|
18846
|
-
p_parent_table := format('%I.%I', $1::text, $2::text),
|
|
18847
|
-
p_control := $3,
|
|
18848
|
-
p_type := 'native',
|
|
18849
|
-
p_interval := '1 day'
|
|
18850
|
-
)`, [
|
|
18851
|
-
schema,
|
|
18852
|
-
table,
|
|
18853
|
-
SIGNAL_TIME_COLUMN[table]
|
|
18854
|
-
]);
|
|
18855
|
-
break;
|
|
18856
|
-
} catch (error) {
|
|
18857
|
-
const message = error?.message ?? "";
|
|
18858
|
-
if (error?.code === "23505" || /already managed by pg_partman/i.test(message) || /relation .* already exists/i.test(message)) break;
|
|
18859
|
-
if (!/deadlock detected/i.test(message)) throw error;
|
|
18860
|
-
if ((await client.oneOrNone(`SELECT EXISTS (
|
|
18861
|
-
SELECT 1 FROM partman.part_config
|
|
18862
|
-
WHERE parent_table = format('%I.%I', $1::text, $2::text)
|
|
18863
|
-
) AS "exists"`, [schema, table]))?.exists) break;
|
|
18864
|
-
if (attempt === 2) throw error;
|
|
18865
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
18866
|
-
}
|
|
18867
|
-
}
|
|
18868
|
-
}
|
|
18869
|
-
async function setupPartitioning(client, schema, options = {}) {
|
|
18870
|
-
switch (await resolveMode(client, options)) {
|
|
18871
|
-
case "timescale":
|
|
18872
|
-
await ensureTimescaleHypertables(client, schema);
|
|
18873
|
-
return "timescale";
|
|
18874
|
-
case "partman":
|
|
18875
|
-
await ensurePartmanHypertables(client, schema);
|
|
18876
|
-
return "partman";
|
|
18877
|
-
case "native":
|
|
18878
|
-
await ensureNativePartitions(client, schema, options);
|
|
18879
|
-
return "native";
|
|
18880
|
-
}
|
|
18881
|
-
}
|
|
18882
19140
|
// src/storage/domains/observability/v-next/retention.ts
|
|
18883
19141
|
/**
|
|
18884
19142
|
* Age-based retention for the v-next Postgres observability domain.
|
|
@@ -19044,16 +19302,14 @@ function collapseExactScoreConflicts(rows) {
|
|
|
19044
19302
|
}
|
|
19045
19303
|
return [...records.values()];
|
|
19046
19304
|
}
|
|
19047
|
-
async function createScore(client, schema, args) {
|
|
19305
|
+
async function createScore(client, schema, args, partitioning) {
|
|
19048
19306
|
const row = scoreRecordToRow(args.score);
|
|
19049
|
-
|
|
19050
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
19307
|
+
await insertRows(client, schema, TABLE_SCORE_EVENTS, [row], partitioning, scoreRewriteConflict(row));
|
|
19051
19308
|
}
|
|
19052
|
-
async function batchCreateScores(client, schema, args) {
|
|
19309
|
+
async function batchCreateScores(client, schema, args, partitioning) {
|
|
19053
19310
|
if (args.scores.length === 0) return;
|
|
19054
19311
|
const rows = collapseExactScoreConflicts(args.scores.map(scoreRecordToRow));
|
|
19055
|
-
|
|
19056
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
19312
|
+
await insertRows(client, schema, TABLE_SCORE_EVENTS, rows, partitioning, scoreRewriteConflict(rows[0]));
|
|
19057
19313
|
}
|
|
19058
19314
|
/**
|
|
19059
19315
|
* Delete score events by scoreId. Optional `organizationId` and `resourceId`
|
|
@@ -19284,7 +19540,15 @@ const TRACE_STATUS_SQL = `CASE WHEN r."error" IS NOT NULL THEN 'error' ELSE 'suc
|
|
|
19284
19540
|
const timeToFirstChunkSupported = "timeToFirstChunkMs" in _mastra_core_storage.TRACE_QUERY_FIELD_REGISTRY.trace;
|
|
19285
19541
|
/** Core before the sort rejects `orderBy` on the field, so the sort is only declared when core plans it. */
|
|
19286
19542
|
const timeToFirstChunkSortSupported = _mastra_core_storage.TRACE_QUERY_ORDER_FIELDS?.includes("timeToFirstChunkMs") === true;
|
|
19287
|
-
|
|
19543
|
+
/** Same rule for time to first token: returned and sorted only when core knows the field. */
|
|
19544
|
+
const timeToFirstTokenSupported = "timeToFirstTokenMs" in _mastra_core_storage.TRACE_QUERY_FIELD_REGISTRY.trace;
|
|
19545
|
+
const timeToFirstTokenSortSupported = _mastra_core_storage.TRACE_QUERY_ORDER_FIELDS?.includes("timeToFirstTokenMs") === true;
|
|
19546
|
+
const timingFeatures = [
|
|
19547
|
+
...timeToFirstChunkSupported ? ["trace-query-time-to-first-chunk"] : [],
|
|
19548
|
+
...timeToFirstChunkSortSupported ? ["trace-query-time-to-first-chunk-sort"] : [],
|
|
19549
|
+
...timeToFirstTokenSupported ? ["trace-query-time-to-first-token"] : [],
|
|
19550
|
+
...timeToFirstTokenSortSupported ? ["trace-query-time-to-first-token-sort"] : []
|
|
19551
|
+
];
|
|
19288
19552
|
/**
|
|
19289
19553
|
* Keyset bounds for a cursor's sort value: terms for rows strictly past it in `direction`, and the
|
|
19290
19554
|
* condition for rows level with it. Empty values sort last in both directions, so they are past
|
|
@@ -19317,6 +19581,7 @@ const TRACE_FIELDS = {
|
|
|
19317
19581
|
endedAt: "r.\"endedAt\"",
|
|
19318
19582
|
durationMs: durationMsSql("r.\"startedAt\"", "r.\"endedAt\""),
|
|
19319
19583
|
timeToFirstChunkMs: "r.\"timeToFirstChunkMs\"",
|
|
19584
|
+
timeToFirstTokenMs: "r.\"timeToFirstTokenMs\"",
|
|
19320
19585
|
entityId: "r.\"entityId\"",
|
|
19321
19586
|
entityName: "r.\"entityName\"",
|
|
19322
19587
|
entityType: "r.\"entityType\"",
|
|
@@ -19335,6 +19600,7 @@ const SPAN_FIELDS = {
|
|
|
19335
19600
|
endedAt: "s.\"endedAt\"",
|
|
19336
19601
|
durationMs: "s.\"durationMs\"",
|
|
19337
19602
|
timeToFirstChunkMs: "s.\"timeToFirstChunkMs\"",
|
|
19603
|
+
timeToFirstTokenMs: "s.\"timeToFirstTokenMs\"",
|
|
19338
19604
|
status: "s.\"status\"",
|
|
19339
19605
|
error: "s.\"error\"",
|
|
19340
19606
|
entityType: "s.\"entityType\"",
|
|
@@ -19405,6 +19671,7 @@ const TRACE_SELECT = `
|
|
|
19405
19671
|
r."startedAt" AS "startedAt",
|
|
19406
19672
|
r."endedAt" AS "endedAt",
|
|
19407
19673
|
r."timeToFirstChunkMs" AS "timeToFirstChunkMs",
|
|
19674
|
+
r."timeToFirstTokenMs" AS "timeToFirstTokenMs",
|
|
19408
19675
|
r."entityName" AS "entityName",
|
|
19409
19676
|
r."entityType" AS "entityType",
|
|
19410
19677
|
r."environment" AS "environment",
|
|
@@ -19774,6 +20041,7 @@ function compilePostgresTraceScope(schema, selection, relationCollections, scope
|
|
|
19774
20041
|
ELSE ${durationMsSql("s.\"startedAt\"", "s.\"endedAt\"")}
|
|
19775
20042
|
END AS "durationMs",
|
|
19776
20043
|
s."timeToFirstChunkMs",
|
|
20044
|
+
s."timeToFirstTokenMs",
|
|
19777
20045
|
CASE WHEN s."error" IS NOT NULL THEN 'error' ELSE 'success' END AS "status",
|
|
19778
20046
|
s."error",
|
|
19779
20047
|
s."entityType",
|
|
@@ -20148,6 +20416,7 @@ function traceRowToResult(row) {
|
|
|
20148
20416
|
startedAt: asIsoTimestamp$1(row.startedAt),
|
|
20149
20417
|
endedAt: asIsoTimestamp$1(row.endedAt),
|
|
20150
20418
|
...timeToFirstChunkSupported && { timeToFirstChunkMs: row.timeToFirstChunkMs == null ? null : Number(row.timeToFirstChunkMs) },
|
|
20419
|
+
...timeToFirstTokenSupported && { timeToFirstTokenMs: row.timeToFirstTokenMs == null ? null : Number(row.timeToFirstTokenMs) },
|
|
20151
20420
|
entityName: row.entityName == null ? null : String(row.entityName),
|
|
20152
20421
|
entityType: row.entityType == null ? null : String(row.entityType),
|
|
20153
20422
|
environment: row.environment == null ? null : String(row.environment),
|
|
@@ -20303,7 +20572,8 @@ const fields = [
|
|
|
20303
20572
|
"experimentId",
|
|
20304
20573
|
"environment",
|
|
20305
20574
|
"executionSource",
|
|
20306
|
-
"timeToFirstChunkMs"
|
|
20575
|
+
"timeToFirstChunkMs",
|
|
20576
|
+
"timeToFirstTokenMs"
|
|
20307
20577
|
];
|
|
20308
20578
|
const identities = identityFields.map((field) => `"${field}"`).join(", ");
|
|
20309
20579
|
const identityOrder = identityFields.map((field) => `"${field}" COLLATE "C" ASC NULLS LAST`).join(", ");
|
|
@@ -20435,9 +20705,10 @@ async function querySpans(client, schema, plan, timeoutMs) {
|
|
|
20435
20705
|
return runWithPostgresTraceQueryTimeout(client, timeoutMs, async (tx) => {
|
|
20436
20706
|
const query = compilePostgresSpanQuery(schema, plan);
|
|
20437
20707
|
const rows = await tx.any(query.text, query.values);
|
|
20438
|
-
const selected = rows.map(({ version: _version, startedAt, endedAt, timeToFirstChunkMs, ...row }) => ({
|
|
20708
|
+
const selected = rows.map(({ version: _version, startedAt, endedAt, timeToFirstChunkMs, timeToFirstTokenMs, ...row }) => ({
|
|
20439
20709
|
...row,
|
|
20440
20710
|
...timeToFirstChunkSupported && { timeToFirstChunkMs },
|
|
20711
|
+
...timeToFirstTokenSupported && { timeToFirstTokenMs },
|
|
20441
20712
|
startedAt: startedAt.toISOString(),
|
|
20442
20713
|
endedAt: endedAt.toISOString()
|
|
20443
20714
|
}));
|
|
@@ -20569,7 +20840,11 @@ function compilePostgresThreadRowsQuery(schema, plan, strategy = "full") {
|
|
|
20569
20840
|
}
|
|
20570
20841
|
const qualify = plan.timeRange.mode === "traces" ? "s.\"inWindow\"" : `s."${plan.timeRange.field}" >= $1 AND s."${plan.timeRange.field}" < $2`;
|
|
20571
20842
|
const filtered = predicateFields(plan.where);
|
|
20572
|
-
const firstContext = [
|
|
20843
|
+
const firstContext = [
|
|
20844
|
+
...contextFields,
|
|
20845
|
+
"timeToFirstChunkMs",
|
|
20846
|
+
"timeToFirstTokenMs"
|
|
20847
|
+
].filter((field) => field === "resourceId" || filtered.has(field));
|
|
20573
20848
|
const lastFields = [
|
|
20574
20849
|
"traceId",
|
|
20575
20850
|
"spanId",
|
|
@@ -20583,7 +20858,7 @@ function compilePostgresThreadRowsQuery(schema, plan, strategy = "full") {
|
|
|
20583
20858
|
text: `WITH ${scopedRoots}, ${strategy === "candidates" ? `${candidates}, ` : ""}current_roots AS (
|
|
20584
20859
|
SELECT DISTINCT ON ("organizationId", "traceId")
|
|
20585
20860
|
"organizationId", "threadId", "traceId", "spanId", "cursorId", "startedAt", "endedAt", "isPending",
|
|
20586
|
-
${firstContext.map((field) => field === "timeToFirstChunkMs" ? `CASE WHEN "spanType" = 'agent_run' THEN "
|
|
20861
|
+
${firstContext.map((field) => field === "timeToFirstChunkMs" || field === "timeToFirstTokenMs" ? `CASE WHEN "spanType" = 'agent_run' THEN "${field}" END AS "${field}"` : `"${field}"`).join(", ")}
|
|
20587
20862
|
${filtered.has("status") ? ", CASE WHEN error IS NULL THEN 'success' ELSE 'error' END AS status" : ""}
|
|
20588
20863
|
FROM scoped_roots ${strategy === "candidates" ? `WHERE (${identity("traceId")}) IN (SELECT ${identity("traceId")} FROM candidate_traces)` : ""}
|
|
20589
20864
|
ORDER BY "organizationId", "traceId", "startedAt" ASC, "spanId" COLLATE "C", "cursorId" DESC
|
|
@@ -20649,6 +20924,7 @@ async function queryThreadRows(client, schema, plan, timeoutMs, strategy = "auto
|
|
|
20649
20924
|
}));
|
|
20650
20925
|
const payloads = await run(`SELECT "organizationId", "traceId", "cursorId", ${contextFields.map((field) => `"${field}"`).join(", ")},
|
|
20651
20926
|
CASE WHEN "spanType" = 'agent_run' THEN "timeToFirstChunkMs" END AS "timeToFirstChunkMs",
|
|
20927
|
+
CASE WHEN "spanType" = 'agent_run' THEN "timeToFirstTokenMs" END AS "timeToFirstTokenMs",
|
|
20652
20928
|
CASE WHEN error IS NULL THEN 'success' ELSE 'error' END AS status,
|
|
20653
20929
|
${[
|
|
20654
20930
|
"input",
|
|
@@ -20677,6 +20953,7 @@ async function queryThreadRows(client, schema, plan, timeoutMs, strategy = "auto
|
|
|
20677
20953
|
endedAt: row.endedAt.toISOString(),
|
|
20678
20954
|
traceCount: Number(row.traceCount),
|
|
20679
20955
|
timeToFirstChunkMs: first.timeToFirstChunkMs,
|
|
20956
|
+
...timeToFirstTokenSupported && { timeToFirstTokenMs: first.timeToFirstTokenMs },
|
|
20680
20957
|
inputPreview: readPreview(first.inputPreview, first.input),
|
|
20681
20958
|
outputPreview: readPreview(last.outputPreview, last.output),
|
|
20682
20959
|
errorPreview: readPreview(last.errorPreview, last.error)
|
|
@@ -21633,10 +21910,9 @@ async function readBranchesStreamHeadCursor(client, span, filters) {
|
|
|
21633
21910
|
return encodeDeltaCursor(await readSafeXactHorizon(client), 0);
|
|
21634
21911
|
}
|
|
21635
21912
|
// src/storage/domains/observability/v-next/tracing.ts
|
|
21636
|
-
async function writeSpanRows(client, schema, rows) {
|
|
21913
|
+
async function writeSpanRows(client, schema, rows, partitioning) {
|
|
21637
21914
|
const deduped = dedupeSpanRows(rows);
|
|
21638
|
-
|
|
21639
|
-
if (insert) await client.query(insert.text, insert.values);
|
|
21915
|
+
await insertRows(client, schema, TABLE_SPAN_EVENTS, deduped, partitioning, spanConflictClause(Object.keys(deduped[0] ?? {})));
|
|
21640
21916
|
}
|
|
21641
21917
|
/**
|
|
21642
21918
|
* Collapse rows that share a primary key within a single batch. Postgres
|
|
@@ -21656,12 +21932,12 @@ function dedupeSpanRows(rows) {
|
|
|
21656
21932
|
}
|
|
21657
21933
|
return [...byKey.values()];
|
|
21658
21934
|
}
|
|
21659
|
-
async function createSpan(client, schema, args) {
|
|
21660
|
-
await writeSpanRows(client, schema, [spanRecordToRow(args.span)]);
|
|
21935
|
+
async function createSpan(client, schema, args, partitioning) {
|
|
21936
|
+
await writeSpanRows(client, schema, [spanRecordToRow(args.span)], partitioning);
|
|
21661
21937
|
}
|
|
21662
|
-
async function batchCreateSpans(client, schema, args) {
|
|
21938
|
+
async function batchCreateSpans(client, schema, args, partitioning) {
|
|
21663
21939
|
if (args.records.length === 0) return;
|
|
21664
|
-
await writeSpanRows(client, schema, args.records.map(spanRecordToRow));
|
|
21940
|
+
await writeSpanRows(client, schema, args.records.map(spanRecordToRow), partitioning);
|
|
21665
21941
|
}
|
|
21666
21942
|
async function getSpans(client, schema, args) {
|
|
21667
21943
|
if (args.spanIds.length === 0) return {
|
|
@@ -21882,6 +22158,18 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
|
|
|
21882
22158
|
return this.#partitionMode;
|
|
21883
22159
|
}
|
|
21884
22160
|
/**
|
|
22161
|
+
* Create any missing day partitions from yesterday through today +
|
|
22162
|
+
* `futureDays`, without the rest of init(). For apps whose runtime role
|
|
22163
|
+
* cannot run DDL: call it on a schedule with a role that owns the
|
|
22164
|
+
* observability tables. Safe to call repeatedly on one instance. Does
|
|
22165
|
+
* nothing with TimescaleDB or pg_partman, which create their own partitions.
|
|
22166
|
+
*/
|
|
22167
|
+
async ensurePartitions() {
|
|
22168
|
+
return this.#run("VNEXT_ENSURE_PARTITIONS", async () => {
|
|
22169
|
+
if ((this.#partitionMode ??= await resolveMode(this.#client, this.#partitioning)) === "native") await ensureNativePartitions(this.#client, this.#schema, this.#partitioning);
|
|
22170
|
+
});
|
|
22171
|
+
}
|
|
22172
|
+
/**
|
|
21885
22173
|
* All five signal tables are insert-only growth tables. The anchor column is
|
|
21886
22174
|
* each table's partition / chunk key, so age-based expiry drops whole day
|
|
21887
22175
|
* partitions instead of deleting rows (see `./retention.ts`). `indexed: true`
|
|
@@ -21985,7 +22273,7 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
|
|
|
21985
22273
|
"trace-query-tenant-scope",
|
|
21986
22274
|
"feedback",
|
|
21987
22275
|
"trace-query-context-ids",
|
|
21988
|
-
...
|
|
22276
|
+
...timingFeatures,
|
|
21989
22277
|
"trace-query-root-span-fields",
|
|
21990
22278
|
"trace-query-root-entity-id"
|
|
21991
22279
|
];
|
|
@@ -22010,11 +22298,23 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
|
|
|
22010
22298
|
"trace-query-tenant-scope",
|
|
22011
22299
|
"feedback",
|
|
22012
22300
|
"trace-query-context-ids",
|
|
22013
|
-
...
|
|
22301
|
+
...timingFeatures,
|
|
22014
22302
|
"trace-query-root-span-fields",
|
|
22015
22303
|
"trace-query-root-entity-id"
|
|
22016
22304
|
];
|
|
22017
22305
|
}
|
|
22306
|
+
/**
|
|
22307
|
+
* Partitioning options for the write path, with `mode` fixed to the mode
|
|
22308
|
+
* this store resolved (in init(), or lazily in prune()) so the write path
|
|
22309
|
+
* does not detect it again. Until then the configured mode stands in and
|
|
22310
|
+
* the write path resolves it itself when a partition is missing.
|
|
22311
|
+
*/
|
|
22312
|
+
#writePartitioning() {
|
|
22313
|
+
return {
|
|
22314
|
+
...this.#partitioning,
|
|
22315
|
+
mode: this.#partitionMode ?? this.#partitioning.mode
|
|
22316
|
+
};
|
|
22317
|
+
}
|
|
22018
22318
|
async #run(op, fn, details) {
|
|
22019
22319
|
try {
|
|
22020
22320
|
return await fn();
|
|
@@ -22023,13 +22323,13 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
|
|
|
22023
22323
|
}
|
|
22024
22324
|
}
|
|
22025
22325
|
async createSpan(args) {
|
|
22026
|
-
await this.#run("CREATE_SPAN", () => createSpan(this.#client, this.#schema, args), {
|
|
22326
|
+
await this.#run("CREATE_SPAN", () => createSpan(this.#client, this.#schema, args, this.#writePartitioning()), {
|
|
22027
22327
|
traceId: args.span.traceId,
|
|
22028
22328
|
spanId: args.span.spanId
|
|
22029
22329
|
});
|
|
22030
22330
|
}
|
|
22031
22331
|
async batchCreateSpans(args) {
|
|
22032
|
-
await this.#run("BATCH_CREATE_SPANS", () => batchCreateSpans(this.#client, this.#schema, args), { count: args.records.length });
|
|
22332
|
+
await this.#run("BATCH_CREATE_SPANS", () => batchCreateSpans(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.records.length });
|
|
22033
22333
|
}
|
|
22034
22334
|
async getSpan(args) {
|
|
22035
22335
|
return this.#run("GET_SPAN", () => getSpan(this.#readClient, this.#schema, args), {
|
|
@@ -22083,22 +22383,22 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
|
|
|
22083
22383
|
return this.#run("LIST_BRANCHES", () => listBranches(this.#readClient, this.#schema, args));
|
|
22084
22384
|
}
|
|
22085
22385
|
async batchCreateLogs(args) {
|
|
22086
|
-
await this.#run("BATCH_CREATE_LOGS", () => batchCreateLogs(this.#client, this.#schema, args), { count: args.logs.length });
|
|
22386
|
+
await this.#run("BATCH_CREATE_LOGS", () => batchCreateLogs(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.logs.length });
|
|
22087
22387
|
}
|
|
22088
22388
|
async batchCreateMetrics(args) {
|
|
22089
|
-
await this.#run("BATCH_CREATE_METRICS", () => batchCreateMetrics(this.#client, this.#schema, args), { count: args.metrics.length });
|
|
22389
|
+
await this.#run("BATCH_CREATE_METRICS", () => batchCreateMetrics(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.metrics.length });
|
|
22090
22390
|
}
|
|
22091
22391
|
async createScore(args) {
|
|
22092
|
-
await this.#run("CREATE_SCORE", () => createScore(this.#client, this.#schema, args));
|
|
22392
|
+
await this.#run("CREATE_SCORE", () => createScore(this.#client, this.#schema, args, this.#writePartitioning()));
|
|
22093
22393
|
}
|
|
22094
22394
|
async batchCreateScores(args) {
|
|
22095
|
-
await this.#run("BATCH_CREATE_SCORES", () => batchCreateScores(this.#client, this.#schema, args), { count: args.scores.length });
|
|
22395
|
+
await this.#run("BATCH_CREATE_SCORES", () => batchCreateScores(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.scores.length });
|
|
22096
22396
|
}
|
|
22097
22397
|
async createFeedback(args) {
|
|
22098
|
-
await this.#run("CREATE_FEEDBACK", () => createFeedback(this.#client, this.#schema, args));
|
|
22398
|
+
await this.#run("CREATE_FEEDBACK", () => createFeedback(this.#client, this.#schema, args, this.#writePartitioning()));
|
|
22099
22399
|
}
|
|
22100
22400
|
async batchCreateFeedback(args) {
|
|
22101
|
-
await this.#run("BATCH_CREATE_FEEDBACK", () => batchCreateFeedback(this.#client, this.#schema, args), { count: args.feedbacks.length });
|
|
22401
|
+
await this.#run("BATCH_CREATE_FEEDBACK", () => batchCreateFeedback(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.feedbacks.length });
|
|
22102
22402
|
}
|
|
22103
22403
|
async deleteScores(args) {
|
|
22104
22404
|
await this.#run("DELETE_SCORES", () => deleteScores(this.#client, this.#schema, args), { count: args.scoreIds.length });
|
|
@@ -27508,12 +27808,20 @@ var PostgresStore = class extends _mastra_core_storage.MastraCompositeStore {
|
|
|
27508
27808
|
}
|
|
27509
27809
|
async #runPinnedInit() {
|
|
27510
27810
|
let pinnedClient;
|
|
27811
|
+
let pinned;
|
|
27511
27812
|
try {
|
|
27512
27813
|
pinnedClient = await this.#writePool.connect();
|
|
27513
|
-
|
|
27814
|
+
pinned = new PinnedClientAdapter(this.#writePool, pinnedClient);
|
|
27514
27815
|
this.#db.pin(pinned);
|
|
27515
|
-
|
|
27516
|
-
|
|
27816
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
27817
|
+
this.#db.setSchemaSnapshot(await loadSchemaSnapshot(pinned, this.schema));
|
|
27818
|
+
const failures = (await Promise.allSettled(Object.entries(this.stores).filter(([name, store]) => name !== "knowledge" && store).map(([, store]) => Promise.resolve().then(() => store.init())))).filter((result) => result.status === "rejected");
|
|
27819
|
+
if (failures.length === 0) break;
|
|
27820
|
+
const terminal = failures.find((result) => !isRetryableDeadlock(result.reason));
|
|
27821
|
+
if (terminal || attempt === 3) throw (terminal ?? failures[0]).reason;
|
|
27822
|
+
this.#db.setSchemaSnapshot(null);
|
|
27823
|
+
await backoffAfterDeadlock(attempt);
|
|
27824
|
+
}
|
|
27517
27825
|
this.isInitialized = true;
|
|
27518
27826
|
} catch (error) {
|
|
27519
27827
|
this.#initPromise = null;
|
|
@@ -27524,6 +27832,7 @@ var PostgresStore = class extends _mastra_core_storage.MastraCompositeStore {
|
|
|
27524
27832
|
category: _mastra_core_error.ErrorCategory.THIRD_PARTY
|
|
27525
27833
|
}, error);
|
|
27526
27834
|
} finally {
|
|
27835
|
+
await pinned?.drain();
|
|
27527
27836
|
this.#db.setSchemaSnapshot(null);
|
|
27528
27837
|
if (pinnedClient) {
|
|
27529
27838
|
this.#db.unpin();
|
|
@@ -27678,6 +27987,10 @@ var PostgresStoreVNext = class extends PostgresStore {
|
|
|
27678
27987
|
owned: true
|
|
27679
27988
|
};
|
|
27680
27989
|
}
|
|
27990
|
+
/** See {@link ObservabilityStoragePostgresVNext.ensurePartitions}. */
|
|
27991
|
+
async ensurePartitions() {
|
|
27992
|
+
await this.stores.observability.ensurePartitions();
|
|
27993
|
+
}
|
|
27681
27994
|
/**
|
|
27682
27995
|
* Closes both the primary pool (when owned) and the observability pool
|
|
27683
27996
|
* (when this store created it). Safe to call multiple times.
|