2026-06-27 08:25:02 +08:00
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
|
|
|
2026-09-02 10:26:46 +08:00
|
|
|
|
import {
|
|
|
|
|
|
mapExperienceRow,
|
|
|
|
|
|
normalizeExperienceRecordInput,
|
|
|
|
|
|
} from './experience-schema.mjs';
|
2026-09-02 14:30:59 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
|
|
|
|
|
planExperienceReflection,
|
|
|
|
|
|
scoreExperienceRow,
|
|
|
|
|
|
} from './experience-reflect.mjs';
|
|
|
|
|
|
import {
|
|
|
|
|
|
MEMORY_V2_PRODUCT_EVENT_TYPES,
|
|
|
|
|
|
recordMemoryV2ProductEvent,
|
|
|
|
|
|
} from './memory-v2-product-events.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
const PG_EXPERIENCE_SELECT = `SELECT id, scope, kind, title, body, tags, source_session_id,
|
|
|
|
|
|
source_user_id, use_count, problem, environment_json, hypothesis,
|
|
|
|
|
|
action_json, result, confidence, evidence_json, parent_experience_id,
|
|
|
|
|
|
supersedes_id, status, created_at, updated_at
|
|
|
|
|
|
FROM h5_experience`;
|
2026-09-02 10:26:46 +08:00
|
|
|
|
|
2026-06-27 08:25:02 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Same contract as the MySQL `createExperienceService` (record / search /
|
|
|
|
|
|
* reflect) so callers (mindspace-agent-runner) are backend-agnostic. The MySQL
|
|
|
|
|
|
* business DB is untouched; only this shared-experience workload lives on PG.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Semantic search via pgvector is used when an `embed(text) => number[]` function
|
|
|
|
|
|
* is supplied; otherwise it degrades to keyword (ILIKE) + recency, identical in
|
|
|
|
|
|
* spirit to the MySQL version, so it is useful before an embedding provider is wired.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The `pg` driver is imported dynamically so this module loads even when the
|
|
|
|
|
|
* dependency is not yet installed; it is only required once the service is built.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {object} options
|
|
|
|
|
|
* @param {string} options.connectionString - e.g. process.env.EXPERIENCE_PG_URL
|
|
|
|
|
|
* @param {(text: string) => Promise<number[]>} [options.embed] - embedding fn
|
|
|
|
|
|
* @param {number} [options.embeddingDim=1536] - vector dimension (must match embed)
|
|
|
|
|
|
* @param {() => number} [options.now]
|
|
|
|
|
|
* @param {number} [options.recencyHalfLifeMs]
|
2026-09-02 14:30:59 +08:00
|
|
|
|
* @param {import('mysql2/promise').Pool} [options.productEventsPool] - MySQL pool for product events
|
2026-06-27 08:25:02 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export async function createPgExperienceService(options = {}) {
|
|
|
|
|
|
const connectionString = options.connectionString;
|
|
|
|
|
|
if (!connectionString) {
|
|
|
|
|
|
throw new Error('createPgExperienceService 需要 connectionString(建议用 EXPERIENCE_PG_URL)');
|
|
|
|
|
|
}
|
|
|
|
|
|
const now = options.now ?? (() => Date.now());
|
2026-09-02 14:30:59 +08:00
|
|
|
|
const productEventsPool = options.productEventsPool ?? null;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
const embed = options.embed ?? null;
|
|
|
|
|
|
const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536));
|
|
|
|
|
|
const halfLifeMs = Math.max(
|
|
|
|
|
|
60_000,
|
|
|
|
|
|
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const { default: pg } = await import('pg');
|
|
|
|
|
|
const pool = new pg.Pool({ connectionString, max: Number(options.poolMax ?? 10) });
|
|
|
|
|
|
|
|
|
|
|
|
// Idempotent setup. pgvector must be available on the server (CREATE EXTENSION).
|
|
|
|
|
|
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
|
|
|
|
await pool.query(`
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS h5_experience (
|
|
|
|
|
|
id UUID PRIMARY KEY,
|
|
|
|
|
|
scope TEXT NOT NULL DEFAULT 'global',
|
|
|
|
|
|
kind TEXT NOT NULL DEFAULT 'lesson',
|
|
|
|
|
|
title TEXT NOT NULL,
|
|
|
|
|
|
body TEXT NOT NULL,
|
|
|
|
|
|
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
|
|
|
|
source_session_id TEXT,
|
|
|
|
|
|
source_user_id TEXT,
|
|
|
|
|
|
use_count BIGINT NOT NULL DEFAULT 0,
|
|
|
|
|
|
embedding vector(${embeddingDim}),
|
2026-09-02 10:26:46 +08:00
|
|
|
|
problem TEXT,
|
|
|
|
|
|
environment_json JSONB,
|
|
|
|
|
|
hypothesis TEXT,
|
|
|
|
|
|
action_json JSONB,
|
|
|
|
|
|
result TEXT,
|
|
|
|
|
|
confidence DOUBLE PRECISION,
|
|
|
|
|
|
evidence_json JSONB,
|
|
|
|
|
|
parent_experience_id UUID,
|
|
|
|
|
|
supersedes_id UUID,
|
|
|
|
|
|
status TEXT NOT NULL DEFAULT 'active',
|
2026-06-27 08:25:02 +08:00
|
|
|
|
created_at BIGINT NOT NULL,
|
|
|
|
|
|
updated_at BIGINT NOT NULL
|
|
|
|
|
|
)
|
|
|
|
|
|
`);
|
2026-09-02 10:26:46 +08:00
|
|
|
|
const pgExperienceColumns = [
|
|
|
|
|
|
['problem', 'TEXT'],
|
|
|
|
|
|
['environment_json', 'JSONB'],
|
|
|
|
|
|
['hypothesis', 'TEXT'],
|
|
|
|
|
|
['action_json', 'JSONB'],
|
|
|
|
|
|
['result', 'TEXT'],
|
|
|
|
|
|
['confidence', 'DOUBLE PRECISION'],
|
|
|
|
|
|
['evidence_json', 'JSONB'],
|
|
|
|
|
|
['parent_experience_id', 'UUID'],
|
|
|
|
|
|
['supersedes_id', 'UUID'],
|
|
|
|
|
|
["status", "TEXT NOT NULL DEFAULT 'active'"],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const [column, definition] of pgExperienceColumns) {
|
|
|
|
|
|
await pool.query(`ALTER TABLE h5_experience ADD COLUMN IF NOT EXISTS ${column} ${definition}`);
|
|
|
|
|
|
}
|
2026-06-27 08:25:02 +08:00
|
|
|
|
await pool.query(
|
|
|
|
|
|
'CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)',
|
|
|
|
|
|
);
|
|
|
|
|
|
// Trigram-ish keyword fallback index (btree on lower(title)) is cheap; the
|
|
|
|
|
|
// ANN vector index is created lazily once rows have embeddings.
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
"CREATE INDEX IF NOT EXISTS idx_h5_experience_embedding ON h5_experience USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
|
|
|
|
|
|
).catch(() => {
|
|
|
|
|
|
// ivfflat needs the extension + may warn on empty table; non-fatal.
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function toVectorLiteral(vec) {
|
|
|
|
|
|
// pgvector accepts a string like '[0.1,0.2,...]'
|
|
|
|
|
|
return `[${vec.map((n) => Number(n)).join(',')}]`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowToExperience(row) {
|
2026-09-02 10:26:46 +08:00
|
|
|
|
return mapExperienceRow({
|
|
|
|
|
|
...row,
|
|
|
|
|
|
tags_json: row.tags,
|
|
|
|
|
|
});
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 14:30:59 +08:00
|
|
|
|
async function emitExperienceSaved(saved, input) {
|
|
|
|
|
|
if (!productEventsPool?.query) return;
|
|
|
|
|
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
|
|
|
|
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_SAVED,
|
|
|
|
|
|
userId: saved.sourceUserId,
|
|
|
|
|
|
sessionId: saved.sourceSessionId,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
experienceId: saved.id,
|
|
|
|
|
|
kind: saved.kind,
|
|
|
|
|
|
scope: saved.scope,
|
|
|
|
|
|
result: saved.result ?? null,
|
|
|
|
|
|
source: input?.source ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
createdAt: saved.createdAt,
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function emitExperienceRecalled({ query, hits, scope }) {
|
|
|
|
|
|
if (!productEventsPool?.query || !hits.length) return;
|
|
|
|
|
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
|
|
|
|
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_RECALLED,
|
|
|
|
|
|
userId: hits[0]?.sourceUserId ?? null,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
query: String(query ?? '').slice(0, 500),
|
|
|
|
|
|
scope,
|
|
|
|
|
|
hitCount: hits.length,
|
|
|
|
|
|
experienceIds: hits.map((hit) => hit.id),
|
|
|
|
|
|
kinds: hits.map((hit) => hit.kind),
|
|
|
|
|
|
},
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rankRows(rows, queryTerms, nowMs) {
|
|
|
|
|
|
return rows
|
|
|
|
|
|
.map((row) => scoreExperienceRow(row, queryTerms, nowMs, halfLifeMs))
|
|
|
|
|
|
.filter((entry) => entry.score > 0)
|
|
|
|
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 08:25:02 +08:00
|
|
|
|
async function record(input) {
|
2026-09-02 10:26:46 +08:00
|
|
|
|
const normalized = normalizeExperienceRecordInput(input);
|
|
|
|
|
|
if (!normalized.title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
|
|
|
|
|
if (!normalized.body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
|
2026-06-27 08:25:02 +08:00
|
|
|
|
const id = crypto.randomUUID();
|
|
|
|
|
|
const ts = now();
|
2026-09-02 10:26:46 +08:00
|
|
|
|
const environmentJson = normalized.environment;
|
|
|
|
|
|
const actionJson = normalized.action;
|
|
|
|
|
|
const evidenceJson = normalized.evidence;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
let embedding = null;
|
|
|
|
|
|
if (embed) {
|
|
|
|
|
|
try {
|
2026-09-02 10:26:46 +08:00
|
|
|
|
const vec = await embed(`${normalized.title}\n${normalized.body}`);
|
2026-06-27 08:25:02 +08:00
|
|
|
|
if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec);
|
|
|
|
|
|
} catch {
|
2026-09-02 10:26:46 +08:00
|
|
|
|
embedding = null;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`INSERT INTO h5_experience
|
|
|
|
|
|
(id, scope, kind, title, body, tags, source_session_id, source_user_id,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
use_count, embedding, problem, environment_json, hypothesis, action_json,
|
|
|
|
|
|
result, confidence, evidence_json, parent_experience_id, supersedes_id,
|
|
|
|
|
|
status, created_at, updated_at)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11::jsonb,$12,$13::jsonb,
|
|
|
|
|
|
$14,$15,$16::jsonb,$17::uuid,$18::uuid,$19,$20,$21)`,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
[
|
|
|
|
|
|
id,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
normalized.scope,
|
|
|
|
|
|
normalized.kind,
|
|
|
|
|
|
normalized.title,
|
|
|
|
|
|
normalized.body,
|
|
|
|
|
|
JSON.stringify(normalized.tags),
|
|
|
|
|
|
normalized.sourceSessionId,
|
|
|
|
|
|
normalized.sourceUserId,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
embedding,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
normalized.problem,
|
|
|
|
|
|
environmentJson == null ? null : JSON.stringify(environmentJson),
|
|
|
|
|
|
normalized.hypothesis,
|
|
|
|
|
|
actionJson == null ? null : JSON.stringify(actionJson),
|
|
|
|
|
|
normalized.result,
|
|
|
|
|
|
normalized.confidence,
|
|
|
|
|
|
evidenceJson == null ? null : JSON.stringify(evidenceJson),
|
|
|
|
|
|
normalized.parentExperienceId,
|
|
|
|
|
|
normalized.supersedesId,
|
|
|
|
|
|
normalized.status,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
ts,
|
|
|
|
|
|
ts,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
2026-09-02 14:30:59 +08:00
|
|
|
|
const saved = rowToExperience({
|
2026-06-27 08:25:02 +08:00
|
|
|
|
id,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
scope: normalized.scope,
|
|
|
|
|
|
kind: normalized.kind,
|
|
|
|
|
|
title: normalized.title,
|
|
|
|
|
|
body: normalized.body,
|
|
|
|
|
|
tags: normalized.tags,
|
|
|
|
|
|
source_session_id: normalized.sourceSessionId,
|
|
|
|
|
|
source_user_id: normalized.sourceUserId,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
use_count: 0,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
problem: normalized.problem,
|
|
|
|
|
|
environment_json: environmentJson,
|
|
|
|
|
|
hypothesis: normalized.hypothesis,
|
|
|
|
|
|
action_json: actionJson,
|
|
|
|
|
|
result: normalized.result,
|
|
|
|
|
|
confidence: normalized.confidence,
|
|
|
|
|
|
evidence_json: evidenceJson,
|
|
|
|
|
|
parent_experience_id: normalized.parentExperienceId,
|
|
|
|
|
|
supersedes_id: normalized.supersedesId,
|
|
|
|
|
|
status: normalized.status,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
created_at: ts,
|
|
|
|
|
|
updated_at: ts,
|
|
|
|
|
|
});
|
2026-09-02 14:30:59 +08:00
|
|
|
|
void emitExperienceSaved(saved, input);
|
|
|
|
|
|
return saved;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function search(query, { scope = 'global', limit = 5 } = {}) {
|
|
|
|
|
|
const text = String(query ?? '').trim();
|
|
|
|
|
|
if (!text) return [];
|
|
|
|
|
|
const max = Math.max(1, limit);
|
|
|
|
|
|
|
|
|
|
|
|
// Vector path: cosine distance ordering, recency as tiebreaker.
|
|
|
|
|
|
if (embed) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const vec = await embed(text);
|
|
|
|
|
|
if (Array.isArray(vec) && vec.length === embeddingDim) {
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`SELECT id, scope, kind, title, body, tags, source_session_id,
|
2026-09-02 10:26:46 +08:00
|
|
|
|
source_user_id, use_count, problem, environment_json, hypothesis,
|
|
|
|
|
|
action_json, result, confidence, evidence_json, parent_experience_id,
|
|
|
|
|
|
supersedes_id, status, created_at, updated_at
|
2026-06-27 08:25:02 +08:00
|
|
|
|
FROM h5_experience
|
2026-09-02 10:26:46 +08:00
|
|
|
|
WHERE scope = $1 AND status = 'active' AND embedding IS NOT NULL
|
2026-06-27 08:25:02 +08:00
|
|
|
|
ORDER BY embedding <=> $2::vector, updated_at DESC
|
|
|
|
|
|
LIMIT $3`,
|
|
|
|
|
|
[scope, toVectorLiteral(vec), max],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (rows.length > 0) {
|
|
|
|
|
|
await bumpUseCount(rows.map((r) => r.id));
|
2026-09-02 14:30:59 +08:00
|
|
|
|
const hits = rows.map(rowToExperience);
|
|
|
|
|
|
void emitExperienceRecalled({ query: text, hits, scope });
|
|
|
|
|
|
return hits;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// fall through to keyword path
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 14:30:59 +08:00
|
|
|
|
// Keyword fallback: fetch candidate rows, rank with environment/problem boost.
|
2026-06-27 08:25:02 +08:00
|
|
|
|
const terms = tokenize(text);
|
|
|
|
|
|
if (terms.length === 0) return [];
|
2026-09-02 14:30:59 +08:00
|
|
|
|
const likeClauses = terms.map(
|
|
|
|
|
|
(_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2} OR problem ILIKE $${i + 2} OR environment_json::text ILIKE $${i + 2})`,
|
|
|
|
|
|
);
|
|
|
|
|
|
const params = [scope, ...terms.map((t) => `%${t}%`)];
|
2026-06-27 08:25:02 +08:00
|
|
|
|
const { rows } = await pool.query(
|
2026-09-02 14:30:59 +08:00
|
|
|
|
`${PG_EXPERIENCE_SELECT}
|
2026-09-02 10:26:46 +08:00
|
|
|
|
WHERE scope = $1 AND status = 'active' AND (${likeClauses.join(' OR ')})
|
2026-06-27 08:25:02 +08:00
|
|
|
|
ORDER BY updated_at DESC
|
2026-09-02 14:30:59 +08:00
|
|
|
|
LIMIT 500`,
|
2026-06-27 08:25:02 +08:00
|
|
|
|
params,
|
|
|
|
|
|
);
|
2026-09-02 14:30:59 +08:00
|
|
|
|
const ranked = rankRows(rows, terms, now()).slice(0, max);
|
|
|
|
|
|
if (ranked.length > 0) await bumpUseCount(ranked.map((entry) => entry.row.id));
|
|
|
|
|
|
const hits = ranked.map((entry) => rowToExperience(entry.row));
|
|
|
|
|
|
void emitExperienceRecalled({ query: text, hits, scope });
|
|
|
|
|
|
return hits;
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function bumpUseCount(ids) {
|
|
|
|
|
|
if (!ids.length) return;
|
|
|
|
|
|
await pool
|
|
|
|
|
|
.query('UPDATE h5_experience SET use_count = use_count + 1 WHERE id = ANY($1::uuid[])', [ids])
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tokenize(query) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
...new Set(
|
|
|
|
|
|
String(query ?? '')
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.split(/[^\p{L}\p{N}]+/u)
|
|
|
|
|
|
.map((t) => t.trim())
|
|
|
|
|
|
.filter((t) => t.length >= 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 14:30:59 +08:00
|
|
|
|
async function reflect({
|
|
|
|
|
|
scope = 'global',
|
|
|
|
|
|
minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
|
|
|
|
|
dryRun = false,
|
|
|
|
|
|
} = {}) {
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`${PG_EXPERIENCE_SELECT}
|
|
|
|
|
|
WHERE scope = $1
|
|
|
|
|
|
ORDER BY updated_at DESC
|
|
|
|
|
|
LIMIT 2000`,
|
|
|
|
|
|
[scope],
|
|
|
|
|
|
);
|
|
|
|
|
|
const plans = planExperienceReflection(rows, { minGroupSize });
|
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
dryRun: true,
|
|
|
|
|
|
scope,
|
|
|
|
|
|
groupsEligible: plans.length,
|
|
|
|
|
|
wouldArchive: plans.reduce((sum, plan) => sum + plan.sourceIds.length, 0),
|
|
|
|
|
|
previews: plans.map((plan) => ({
|
|
|
|
|
|
fingerprint: plan.fingerprint,
|
|
|
|
|
|
sourceCount: plan.sourceIds.length,
|
|
|
|
|
|
title: plan.payload.title,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let created = 0;
|
|
|
|
|
|
let archived = 0;
|
|
|
|
|
|
const patterns = [];
|
|
|
|
|
|
for (const plan of plans) {
|
|
|
|
|
|
const saved = await record({
|
|
|
|
|
|
...plan.payload,
|
|
|
|
|
|
scope,
|
|
|
|
|
|
source: 'reflect',
|
|
|
|
|
|
});
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`UPDATE h5_experience
|
|
|
|
|
|
SET status = 'archived', parent_experience_id = $1::uuid, updated_at = $2
|
|
|
|
|
|
WHERE id = ANY($3::uuid[])`,
|
|
|
|
|
|
[saved.id, now(), plan.sourceIds],
|
|
|
|
|
|
);
|
|
|
|
|
|
created += 1;
|
|
|
|
|
|
archived += plan.sourceIds.length;
|
|
|
|
|
|
patterns.push(saved);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
dryRun: false,
|
|
|
|
|
|
scope,
|
|
|
|
|
|
created,
|
|
|
|
|
|
archived,
|
|
|
|
|
|
patterns,
|
|
|
|
|
|
};
|
2026-06-27 08:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function close() {
|
|
|
|
|
|
await pool.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { record, search, reflect, close };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function experienceError(message, code) {
|
|
|
|
|
|
return Object.assign(new Error(message), { code });
|
|
|
|
|
|
}
|