@mastra/pg 1.36.0-alpha.2 → 1.36.0-alpha.3

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.js CHANGED
@@ -11655,6 +11655,13 @@ function dedupeMessagesForSave(messages) {
11655
11655
  }
11656
11656
  return Array.from(deduped.values());
11657
11657
  }
11658
+ /** Shallow merge; keys patched to `undefined` are dropped when serialized. Non-object metadata is replaced. */
11659
+ function mergeMetadata(existing, patch) {
11660
+ return {
11661
+ ...existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {},
11662
+ ...patch
11663
+ };
11664
+ }
11658
11665
  var MemoryPG = class MemoryPG extends MemoryStorage {
11659
11666
  supportsPartialThreadUpdate = true;
11660
11667
  supportsObservationalMemory = true;
@@ -12221,7 +12228,7 @@ var MemoryPG = class MemoryPG extends MemoryStorage {
12221
12228
  const createdAt = toUtcISOString(thread.createdAt);
12222
12229
  const updatedAt = toUtcISOString(thread.updatedAt);
12223
12230
  const metadataJson = thread.metadata ? toPgJson(thread.metadata) : null;
12224
- await this.#fenced(fence, "saveThread", (q) => q.none(`INSERT INTO ${tableName} (
12231
+ const saved = await this.#fenced(fence, "saveThread", (q) => q.oneOrNone(`INSERT INTO ${tableName} AS t (
12225
12232
  id,
12226
12233
  "resourceId",
12227
12234
  title,
@@ -12232,13 +12239,12 @@ var MemoryPG = class MemoryPG extends MemoryStorage {
12232
12239
  "updatedAtZ"
12233
12240
  ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
12234
12241
  ON CONFLICT (id) DO UPDATE SET
12235
- "resourceId" = EXCLUDED."resourceId",
12236
12242
  title = EXCLUDED.title,
12237
12243
  metadata = EXCLUDED.metadata,
12238
- "createdAt" = EXCLUDED."createdAt",
12239
- "createdAtZ" = EXCLUDED."createdAtZ",
12240
12244
  "updatedAt" = EXCLUDED."updatedAt",
12241
- "updatedAtZ" = EXCLUDED."updatedAtZ"`, [
12245
+ "updatedAtZ" = EXCLUDED."updatedAtZ"
12246
+ WHERE t."resourceId" = EXCLUDED."resourceId"
12247
+ RETURNING *`, [
12242
12248
  thread.id,
12243
12249
  thread.resourceId,
12244
12250
  thread.title,
@@ -12248,12 +12254,26 @@ var MemoryPG = class MemoryPG extends MemoryStorage {
12248
12254
  updatedAt,
12249
12255
  updatedAt
12250
12256
  ]));
12257
+ if (!saved) throw new MastraError({
12258
+ id: createStorageErrorId("PG", "SAVE_THREAD", "RESOURCE_ID_MISMATCH"),
12259
+ domain: ErrorDomain.STORAGE,
12260
+ category: ErrorCategory.USER,
12261
+ text: `Thread ${thread.id} already exists and belongs to a different resource. Use updateThreadResourceId to move a thread and its messages to another resource.`,
12262
+ details: {
12263
+ threadId: thread.id,
12264
+ resourceId: thread.resourceId
12265
+ }
12266
+ });
12251
12267
  return {
12252
- ...thread,
12253
- metadata: metadataJson ? JSON.parse(metadataJson) : thread.metadata
12268
+ id: saved.id,
12269
+ resourceId: saved.resourceId,
12270
+ title: saved.title,
12271
+ metadata: saved.metadata == null ? void 0 : typeof saved.metadata === "string" ? JSON.parse(saved.metadata) : saved.metadata,
12272
+ createdAt: saved.createdAtZ || saved.createdAt,
12273
+ updatedAt: saved.updatedAtZ || saved.updatedAt
12254
12274
  };
12255
12275
  } catch (error) {
12256
- if (isRunFenceConflictError(error)) throw error;
12276
+ if (error instanceof MastraError || isRunFenceConflictError(error)) throw error;
12257
12277
  throw new MastraError({
12258
12278
  id: createStorageErrorId("PG", "SAVE_THREAD", "FAILED"),
12259
12279
  domain: ErrorDomain.STORAGE,
@@ -12262,52 +12282,125 @@ var MemoryPG = class MemoryPG extends MemoryStorage {
12262
12282
  }, error);
12263
12283
  }
12264
12284
  }
12265
- async updateThread({ id, title, metadata, fence }) {
12285
+ #toThread(row) {
12286
+ return {
12287
+ id: row.id,
12288
+ resourceId: row.resourceId,
12289
+ title: row.title,
12290
+ metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
12291
+ createdAt: row.createdAtZ || row.createdAt,
12292
+ updatedAt: row.updatedAtZ || row.updatedAt
12293
+ };
12294
+ }
12295
+ async #lockThread(t, tableName, { id, resourceId }) {
12296
+ const params = [id];
12297
+ let query = `SELECT * FROM ${tableName} WHERE id = $1`;
12298
+ if (resourceId !== void 0) {
12299
+ query += ` AND "resourceId" = $2`;
12300
+ params.push(resourceId);
12301
+ }
12302
+ const row = await t.oneOrNone(`${query} FOR UPDATE`, params);
12303
+ return row ? this.#toThread(row) : null;
12304
+ }
12305
+ /**
12306
+ * Writes `title` (when given) and the full `metadata` value (when given).
12307
+ * Callers that merge metadata must hold the row lock from `#lockThread`.
12308
+ */
12309
+ async #writeThread(q, tableName, { id, title, metadata }) {
12310
+ const nowStr = toUtcISOString(/* @__PURE__ */ new Date());
12311
+ const params = [
12312
+ title ?? null,
12313
+ nowStr,
12314
+ nowStr,
12315
+ id
12316
+ ];
12317
+ let metadataSet = "";
12318
+ if (metadata !== void 0) {
12319
+ params.push(toPgJson(metadata));
12320
+ metadataSet = "metadata = $5,";
12321
+ }
12322
+ const row = await q.oneOrNone(`UPDATE ${tableName}
12323
+ SET
12324
+ title = COALESCE($1, title),
12325
+ ${metadataSet}
12326
+ "updatedAt" = $2,
12327
+ "updatedAtZ" = $3
12328
+ WHERE id = $4
12329
+ RETURNING *`, params);
12330
+ return row ? this.#toThread(row) : null;
12331
+ }
12332
+ /**
12333
+ * Cross-process atomic metadata read-modify-write: the thread row stays locked
12334
+ * (`SELECT ... FOR UPDATE`) from the read through the write, so concurrent
12335
+ * writers from any process serialize instead of overwriting each other.
12336
+ */
12337
+ async updateThreadMetadata({ id, resourceId, update, fence: explicitFence }) {
12338
+ const fence = resolveRunFence(this, explicitFence);
12339
+ const threadsTable = getTableName$3({
12340
+ indexName: TABLE_THREADS,
12341
+ schemaName: getSchemaName$3(this.#schema)
12342
+ });
12343
+ try {
12344
+ return await this.#db.client.tx(async (t) => {
12345
+ if (fence) await assertRunFence(t, this.#runFencesTable(), fence, "updateThreadMetadata");
12346
+ const thread = await this.#lockThread(t, threadsTable, {
12347
+ id,
12348
+ resourceId
12349
+ });
12350
+ if (!thread) return null;
12351
+ const metadata = update(thread);
12352
+ if (!metadata) return thread;
12353
+ return this.#writeThread(t, threadsTable, {
12354
+ id,
12355
+ metadata: mergeMetadata(thread.metadata, metadata)
12356
+ });
12357
+ });
12358
+ } catch (error) {
12359
+ if (error instanceof MastraError || isRunFenceConflictError(error)) throw error;
12360
+ throw new MastraError({
12361
+ id: createStorageErrorId("PG", "UPDATE_THREAD_METADATA", "FAILED"),
12362
+ domain: ErrorDomain.STORAGE,
12363
+ category: ErrorCategory.THIRD_PARTY,
12364
+ details: { threadId: id }
12365
+ }, error);
12366
+ }
12367
+ }
12368
+ async updateThread({ id, title, metadata, fence: explicitFence }) {
12266
12369
  const threadTableName = getTableName$3({
12267
12370
  indexName: TABLE_THREADS,
12268
12371
  schemaName: getSchemaName$3(this.#schema)
12269
12372
  });
12373
+ const notFound = () => new MastraError({
12374
+ id: createStorageErrorId("PG", "UPDATE_THREAD", "FAILED"),
12375
+ domain: ErrorDomain.STORAGE,
12376
+ category: ErrorCategory.USER,
12377
+ text: `Thread ${id} not found`,
12378
+ details: {
12379
+ threadId: id,
12380
+ title: title ?? null
12381
+ }
12382
+ });
12270
12383
  try {
12271
- return await this.#fenced(fence, "updateThread", async (q) => {
12272
- const existingThread = await this.#getThreadById(q, { threadId: id });
12273
- if (!existingThread) throw new MastraError({
12274
- id: createStorageErrorId("PG", "UPDATE_THREAD", "FAILED"),
12275
- domain: ErrorDomain.STORAGE,
12276
- category: ErrorCategory.USER,
12277
- text: `Thread ${id} not found`,
12278
- details: {
12279
- threadId: id,
12280
- title: title ?? null
12281
- }
12384
+ if (metadata === void 0) return await this.#fenced(explicitFence, "updateThread", async (q) => {
12385
+ const thread = await this.#writeThread(q, threadTableName, {
12386
+ id,
12387
+ title
12282
12388
  });
12283
- const mergedMetadata = {
12284
- ...existingThread.metadata,
12285
- ...metadata
12286
- };
12287
- const nowStr = toUtcISOString(/* @__PURE__ */ new Date());
12288
- const thread = await q.one(`UPDATE ${threadTableName}
12289
- SET
12290
- title = COALESCE($1, title),
12291
- metadata = $2,
12292
- "updatedAt" = $3,
12293
- "updatedAtZ" = $4
12294
- WHERE id = $5
12295
- RETURNING *
12296
- `, [
12297
- title ?? null,
12298
- mergedMetadata,
12299
- nowStr,
12300
- nowStr,
12301
- id
12302
- ]);
12303
- return {
12304
- id: thread.id,
12305
- resourceId: thread.resourceId,
12306
- title: thread.title,
12307
- metadata: typeof thread.metadata === "string" ? JSON.parse(thread.metadata) : thread.metadata,
12308
- createdAt: thread.createdAtZ || thread.createdAt,
12309
- updatedAt: thread.updatedAtZ || thread.updatedAt
12310
- };
12389
+ if (!thread) throw notFound();
12390
+ return thread;
12391
+ });
12392
+ const fence = resolveRunFence(this, explicitFence);
12393
+ return await this.#db.client.tx(async (t) => {
12394
+ if (fence) await assertRunFence(t, this.#runFencesTable(), fence, "updateThread");
12395
+ const existing = await this.#lockThread(t, threadTableName, { id });
12396
+ if (!existing) throw notFound();
12397
+ const thread = await this.#writeThread(t, threadTableName, {
12398
+ id,
12399
+ title,
12400
+ metadata: mergeMetadata(existing.metadata, metadata)
12401
+ });
12402
+ if (!thread) throw notFound();
12403
+ return thread;
12311
12404
  });
12312
12405
  } catch (error) {
12313
12406
  if (error instanceof MastraError || isRunFenceConflictError(error)) throw error;
@@ -17363,8 +17456,12 @@ ${onConflict}`,
17363
17456
  async function insertRows(client, schema, table, records, partitioning, onConflict) {
17364
17457
  const insert = buildInsert(schema, table, records, onConflict);
17365
17458
  if (!insert) return;
17459
+ await withPartitionRetry(client, schema, table, records, partitioning, () => client.query(insert.text, insert.values));
17460
+ }
17461
+ /** Retry a complete write after creating a missing native partition, outside any failed transaction. */
17462
+ async function withPartitionRetry(client, schema, table, records, partitioning, write) {
17366
17463
  try {
17367
- await client.query(insert.text, insert.values);
17464
+ return await write();
17368
17465
  } catch (error) {
17369
17466
  if (!isMissingPartitionError(error) || await resolveMode(client, partitioning) !== "native") throw error;
17370
17467
  const days = withinPartitionWindow(recordDays(records, SIGNAL_TIME_COLUMN[table]), partitioning);
@@ -17377,7 +17474,7 @@ async function insertRows(client, schema, table, records, partitioning, onConfli
17377
17474
  cause
17378
17475
  }), { cause });
17379
17476
  });
17380
- await client.query(insert.text, insert.values);
17477
+ return await write();
17381
17478
  }
17382
17479
  }
17383
17480
  /** `42501`: the role may not create the child, or does not own the parent it must attach to. */
@@ -18311,13 +18408,47 @@ function pushFeedbackIdentity(acc, feedbackType, feedbackSource) {
18311
18408
  acc.conditions.push(`"valueNumber" IS NOT NULL`);
18312
18409
  }
18313
18410
  async function createFeedback(client, schema, args, partitioning) {
18314
- const row = feedbackRecordToRow(args.feedback);
18315
- await insertRows(client, schema, TABLE_FEEDBACK_EVENTS, [row], partitioning, FEEDBACK_UPSERT_CLAUSE);
18411
+ await writeFeedback(client, schema, [args.feedback], args.onConflict, partitioning);
18412
+ }
18413
+ async function replaceFeedback(client, schema, args, partitioning) {
18414
+ await writeFeedback(client, schema, [args.feedback], "replace", partitioning, true);
18316
18415
  }
18317
18416
  async function batchCreateFeedback(client, schema, args, partitioning) {
18318
- if (args.feedbacks.length === 0) return;
18319
- const rows = [...new Map(args.feedbacks.map((feedback) => [feedback.feedbackId, feedback])).values()].map(feedbackRecordToRow);
18320
- await insertRows(client, schema, TABLE_FEEDBACK_EVENTS, rows, partitioning, FEEDBACK_UPSERT_CLAUSE);
18417
+ await writeFeedback(client, schema, args.feedbacks, args.onConflict, partitioning);
18418
+ }
18419
+ /** Serialize feedback writes by ID, including absent IDs and retries with a different timestamp. */
18420
+ async function writeFeedback(client, schema, feedbacks, onConflict, partitioning, preserveIdentity = false) {
18421
+ if (!feedbacks.length) return;
18422
+ const currentFeedback = new Map((onConflict === "ignore" ? [...feedbacks].reverse() : feedbacks).map((feedback) => [feedback.feedbackId, feedback]));
18423
+ const rows = [...currentFeedback.values()].map(feedbackRecordToRow);
18424
+ const ids = [...currentFeedback.keys()].sort();
18425
+ await withPartitionRetry(client, schema, TABLE_FEEDBACK_EVENTS, rows, partitioning, () => client.tx(async (tx) => {
18426
+ await tx.query(`SELECT pg_advisory_xact_lock(hashtextextended(lock_key, 0))
18427
+ FROM unnest($1::text[]) WITH ORDINALITY AS locks(lock_key, position)
18428
+ ORDER BY position`, [ids.map((id) => JSON.stringify([
18429
+ schema,
18430
+ TABLE_FEEDBACK_EVENTS,
18431
+ id
18432
+ ]))]);
18433
+ const existing = await tx.any(`SELECT DISTINCT ON ("feedbackId") "feedbackId", "traceId", "timestamp", "organizationId", "resourceId"
18434
+ FROM ${qualifiedTable(schema, TABLE_FEEDBACK_EVENTS)}
18435
+ WHERE "feedbackId" = ANY($1::text[]) ORDER BY "feedbackId", "cursorId" DESC`, [ids]);
18436
+ const byId = new Map(existing.map((row) => [row.feedbackId, row]));
18437
+ const accepted = rows.filter((row) => {
18438
+ const previous = byId.get(row.feedbackId);
18439
+ if (!previous) return true;
18440
+ if (onConflict === "ignore") return false;
18441
+ if (preserveIdentity && (previous.traceId !== row.traceId || new Date(previous.timestamp).getTime() !== new Date(row.timestamp).getTime() || (previous.organizationId ?? null) !== (row.organizationId ?? null) || (previous.resourceId ?? null) !== (row.resourceId ?? null))) throw new MastraError({
18442
+ id: "OBSERVABILITY_FEEDBACK_IDENTITY_CONFLICT",
18443
+ domain: ErrorDomain.MASTRA_OBSERVABILITY,
18444
+ category: ErrorCategory.USER,
18445
+ text: "Feedback replacement must preserve its trace, timestamp, and tenant"
18446
+ });
18447
+ return true;
18448
+ });
18449
+ const insert = buildInsert(schema, TABLE_FEEDBACK_EVENTS, accepted, FEEDBACK_UPSERT_CLAUSE);
18450
+ if (insert) await tx.query(insert.text, insert.values);
18451
+ }));
18321
18452
  }
18322
18453
  async function getFeedbackById(client, schema, feedbackId) {
18323
18454
  const row = await client.oneOrNone(`SELECT ${FEEDBACK_SELECT_COLUMNS} FROM ${qualifiedTable(schema, TABLE_FEEDBACK_EVENTS)}
@@ -22008,6 +22139,7 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
22008
22139
  "thread-query",
22009
22140
  "trace-query-tenant-scope",
22010
22141
  "feedback",
22142
+ "feedback-replacement",
22011
22143
  "trace-query-context-ids",
22012
22144
  ...contextFieldFeatures,
22013
22145
  ...timingFeatures,
@@ -22036,6 +22168,7 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
22036
22168
  "thread-query",
22037
22169
  "trace-query-tenant-scope",
22038
22170
  "feedback",
22171
+ "feedback-replacement",
22039
22172
  "trace-query-context-ids",
22040
22173
  ...contextFieldFeatures,
22041
22174
  ...timingFeatures,
@@ -22135,6 +22268,9 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
22135
22268
  async batchCreateScores(args) {
22136
22269
  await this.#run("BATCH_CREATE_SCORES", () => batchCreateScores(this.#client, this.#schema, args, this.#writePartitioning()), { count: args.scores.length });
22137
22270
  }
22271
+ async writeFeedbackReplacement(args) {
22272
+ await this.#run("REPLACE_FEEDBACK", () => replaceFeedback(this.#client, this.#schema, args, this.#writePartitioning()));
22273
+ }
22138
22274
  async createFeedback(args) {
22139
22275
  await this.#run("CREATE_FEEDBACK", () => createFeedback(this.#client, this.#schema, args, this.#writePartitioning()));
22140
22276
  }
@@ -22160,7 +22296,7 @@ var ObservabilityStoragePostgresVNext = class ObservabilityStoragePostgresVNext
22160
22296
  return this.#run("GET_SCORE_BY_ID", () => getScoreById(this.#readClient, this.#schema, scoreId), { scoreId });
22161
22297
  }
22162
22298
  async getFeedbackById(feedbackId) {
22163
- return this.#run("GET_FEEDBACK_BY_ID", () => getFeedbackById(this.#readClient, this.#schema, feedbackId), { feedbackId });
22299
+ return this.#run("GET_FEEDBACK_BY_ID", () => getFeedbackById(this.#client, this.#schema, feedbackId), { feedbackId });
22164
22300
  }
22165
22301
  async listFeedback(args) {
22166
22302
  return this.#run("LIST_FEEDBACK", () => listFeedback(this.#readClient, this.#schema, args));