Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
108 lines
4.2 KiB
JavaScript
108 lines
4.2 KiB
JavaScript
/**
|
|
* ADR-0068 WP-B — Single source of truth for the `bound_table_id = 0`
|
|
* sentinel that means "this conversation/message is bound to another
|
|
* CONVERSATION row" (not to a universal-tables row).
|
|
*
|
|
* Background — every conversation and message carries a
|
|
* (`bound_table_id`, `bound_row_id`) pair that points at the CRM row this
|
|
* chat is attached to. Until WP-B, `bound_table_id` always referenced a
|
|
* universal_tables.id. WP-B introduces a child-of-parent chat (comment
|
|
* thread): the child's bound row IS its parent conversation, so it needs
|
|
* a way to say "bound to a conversation, not a table row".
|
|
*
|
|
* Architect-locked rule (chat 2026-05-19): `bound_table_id = 0` is the
|
|
* sentinel for that case, no new sentinel registry. Every join /
|
|
* row-resolver MUST funnel through `resolveAttachedRow()` here — that way
|
|
* if the sentinel ever changes, exactly one place updates.
|
|
*
|
|
* Use cases today (post-WP-B):
|
|
* - Chat list "attached to" chip → fetch parent conversation title to
|
|
* render the chip on the child.
|
|
* - send_widget_message → resolve table_name/row_title for the
|
|
* attachment payload (universal path; sentinel rerouted to
|
|
* conversations.title).
|
|
*
|
|
* Callers should treat the result as best-effort metadata:
|
|
* - `{ table_name, row_title }` when the bound entity exists.
|
|
* - `null` when missing/deleted (caller decides whether to drop the chip
|
|
* or render a tombstone).
|
|
*/
|
|
|
|
import { dbGet } from '../../routes/v3/chat/chatShared.js';
|
|
|
|
export const CONVERSATIONS_SENTINEL = 0;
|
|
// Public alias — readable at call sites that aren't about resolution but about
|
|
// stamping the sentinel on outgoing inserts (e.g. POST /comment-thread).
|
|
export const BOUND_TABLE_ID_CONVERSATIONS = CONVERSATIONS_SENTINEL;
|
|
|
|
/**
|
|
* Resolve a (bound_table_id, bound_row_id) pair to display metadata.
|
|
* Handles the `bound_table_id = 0` sentinel by querying `conversations`
|
|
* instead of `universal_tables` + `table_rows`.
|
|
*
|
|
* @param {object} args
|
|
* @param {number|null} args.bound_table_id
|
|
* @param {number|null} args.bound_row_id
|
|
* @returns {Promise<{table_name: string, row_title: string|null, source: 'conversation'|'universal_table'} | null>}
|
|
*/
|
|
export async function resolveAttachedRow({ bound_table_id, bound_row_id } = {}) {
|
|
const tableId = Number(bound_table_id);
|
|
const rowId = Number(bound_row_id);
|
|
if (!Number.isFinite(rowId) || rowId <= 0) return null;
|
|
if (!Number.isFinite(tableId)) return null;
|
|
|
|
if (tableId === CONVERSATIONS_SENTINEL) {
|
|
const row = await dbGet(
|
|
`SELECT id, title, type FROM conversations WHERE id = $1`,
|
|
[rowId]
|
|
);
|
|
if (!row) return null;
|
|
return {
|
|
table_name: 'conversations',
|
|
row_title: row.title || `Conversation #${row.id}`,
|
|
source: 'conversation',
|
|
};
|
|
}
|
|
|
|
// Universal-table path — keep it minimal here; full row data resolution
|
|
// belongs to higher layers that already do JOINs against table_rows.
|
|
const ut = await dbGet(
|
|
`SELECT name FROM universal_tables WHERE id = $1`,
|
|
[tableId]
|
|
);
|
|
if (!ut) return null;
|
|
return {
|
|
table_name: ut.name,
|
|
row_title: null,
|
|
source: 'universal_table',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True when bound_table_id addresses a conversation row.
|
|
* Cheap predicate so callers can branch without going through dbGet.
|
|
* Distinguishes the sentinel (0) from null/undefined — `Number(null)` coerces
|
|
* to 0 in JS so we have to reject nullish inputs explicitly.
|
|
*/
|
|
export function isConversationSentinel(bound_table_id) {
|
|
if (bound_table_id == null) return false;
|
|
return Number(bound_table_id) === CONVERSATIONS_SENTINEL;
|
|
}
|
|
|
|
/**
|
|
* Find the active comment-thread child for a given parent conversation.
|
|
* Returns the child's id or null. Archived children (purpose='comments_archived',
|
|
* parent_conversation_id NULL after the cascade-archive trigger) are intentionally
|
|
* skipped — the find-or-create idempotency only matches live links.
|
|
*/
|
|
export async function getCommentThreadChildId(parentConversationId) {
|
|
const pid = Number(parentConversationId);
|
|
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
const row = await dbGet(
|
|
`SELECT id FROM conversations
|
|
WHERE parent_conversation_id = $1 AND purpose = 'comments'
|
|
LIMIT 1`,
|
|
[pid]
|
|
);
|
|
return row?.id || null;
|
|
}
|