T-HUB/apps/api/src/feeds/column-store.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

652 lines
34 KiB
TypeScript

/**
* Columns persistence (P1 — design §1.2/§1.4).
*
* `stream_columns` (021) is the persisted column list; this repository owns
* every mutation plus the resolved-source rows in `column_sources` (026) and
* the per-source item rows in `feed_items`/`feed_item_columns` written by the
* poll loop. Reads run under `withTenant` so FORCE RLS binds honestly.
*
* Everything the client can send is parsed with the P0a core validators
* (`parseStreamColumnSpec`, `parseRefreshPolicyClamped`, `clampTitle`,
* `clampPosition`) — the DB CHECKs are backstops, never the validation layer.
* `StreamColumnsValidationError` is translated to 422 at the controller.
*/
import { BadRequestException, Inject, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
import { createHash } from "node:crypto";
import type { PoolClient } from "pg";
import {
clampPosition, clampTitle, parseRefreshPolicyClamped, parseStreamColumnSpec,
StreamColumnsValidationError, type RefreshPolicy, type StreamColumnSpec, type StreamColumnType,
} from "@tactical-media-broadcast-hub/core";
import type { SessionPayload } from "../auth/session.js";
import { withTenant } from "../db/client.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import type { NormalizedFeedItem } from "./feed-normalize.js";
import type { ConnectionSummary } from "../connections/connections.service.js";
export type ColumnSourceKind = "home" | "mentions" | "search" | "author";
export type ColumnSourceState =
| "pending" | "ready" | "stale" | "error" | "unsupported" | "rate_limited" | "budget_paused" | "disabled";
export interface ColumnRecord {
id: string;
type: StreamColumnType;
title: string;
position: number;
spec: StreamColumnSpec;
refreshPolicy: RefreshPolicy;
createdAt: string;
updatedAt: string;
}
/** `ColumnView.source` (design §1.4): the scheduler-facing row, squashed. */
export interface ColumnSourceView {
id: string;
/** Owning column — present so a bulk source query can be grouped per column. */
columnId: string;
provider: string;
connectionId: string | null;
sourceKind: ColumnSourceKind;
query: string | null;
actorRef: string | null;
state: ColumnSourceState;
lastError: string | null;
lastPolledAt: string | null;
nextPollAt: string | null;
consecutiveFailures: number;
}
export interface CreateColumn {
type: StreamColumnType;
title: string;
position?: number;
spec: unknown;
refreshPolicy?: unknown;
}
export interface UpdateColumn {
title?: string;
position?: number;
spec?: unknown;
refreshPolicy?: unknown;
}
export interface StoredFeedItem {
id: string;
dedupKey: string;
createdAtRemote: string;
item: NormalizedFeedItem;
}
/** The identity one poll resolves to — see `sourceIdentities` below. */
interface SourceIdentity {
provider: string;
connectionId: string | null;
sourceKind: ColumnSourceKind;
query: string | null;
actorRef: string | null;
}
const identityKey = (identity: SourceIdentity): string =>
JSON.stringify([identity.provider, identity.connectionId, identity.sourceKind, identity.query, identity.actorRef]);
export function mapValidationError<T>(parse: () => T): T {
try {
return parse();
} catch (error) {
if (error instanceof StreamColumnsValidationError) throw new UnprocessableEntityException(error.message);
throw error;
}
}
/**
* sha256(workspace | provider | canonical remote id) — design §3.3. The
* canonicalization keeps a provider that restates an item with decorations
* (e.g. Mastodon's `scheduled:123` vs `123`) from double-storing the same
* remote object.
*/
export function dedupKeyFor(workspaceId: string, provider: string, remoteId: string): string {
const canonical = remoteId.replace(/^scheduled:/, "");
return createHash("sha256").update(`${workspaceId}|${provider}|${canonical}`).digest("hex");
}
/**
* The polled identities a column spec resolves to (§3.3/§3.4). platform_feed
* is the degenerate case: one connection, one kind per source row. keyword and
* saved_search expand to one keyless search source per platform. account_list
* expands to one author source per account entry.
*/
export function sourceIdentities(type: StreamColumnType, spec: StreamColumnSpec): SourceIdentity[] {
switch (type) {
case "platform_feed": {
const feed = spec as import("@tactical-media-broadcast-hub/core").PlatformFeedSpec;
const kinds = feed.kinds.length > 0 ? feed.kinds : (["home"] as const);
return kinds.map((kind): SourceIdentity => ({
provider: "", connectionId: feed.connectionId, sourceKind: kind, query: null, actorRef: null,
}));
}
case "keyword":
case "saved_search": {
const keyword = spec as import("@tactical-media-broadcast-hub/core").KeywordSpec;
return keyword.platforms.map((provider): SourceIdentity => ({
provider, connectionId: null, sourceKind: "search", query: keyword.query, actorRef: null,
}));
}
case "account_list": {
const list = spec as import("@tactical-media-broadcast-hub/core").AccountListSpec;
return list.accounts.map((account): SourceIdentity => ({
provider: account.provider, connectionId: null, sourceKind: "author", query: null, actorRef: account.handle,
}));
}
}
}
/** Providers referenced by a spec (empty for platform_feed: the connection supplies it). */
export function columnProviders(type: StreamColumnType, spec: StreamColumnSpec): string[] {
switch (type) {
case "platform_feed": return [];
case "keyword":
case "saved_search": return (spec as import("@tactical-media-broadcast-hub/core").KeywordSpec).platforms;
case "account_list": return (spec as import("@tactical-media-broadcast-hub/core").AccountListSpec).accounts.map((account) => account.provider);
}
}
/** The title today's implicit column renders: "Display Name · handle" (web streams-data liveColumns). */
export function seedTitle(row: ConnectionSummary): string {
const displayName = String(row.fields.displayName ?? "").trim() || row.channelId;
const handle = String(row.fields.handle ?? "").trim() || row.channelId;
return clampTitle(`${displayName} · ${handle}`);
}
interface ColumnRow {
id: string; type: StreamColumnType; title: string; position: number;
spec: unknown; refresh_policy: unknown; created_at: Date; updated_at: Date;
}
const COLUMN_SELECT = "id, type, title, position, spec, refresh_policy, created_at, updated_at";
function toRecord(row: ColumnRow): ColumnRecord {
return {
id: row.id,
type: row.type,
title: row.title,
position: row.position,
spec: mapValidationError(() => parseStreamColumnSpec(row.type, row.spec)),
refreshPolicy: parseRefreshPolicyClamped(row.refresh_policy ?? {}),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
const SOURCE_SELECT = `SELECT s.id, s.column_id, s.provider, s.connection_id, s.source_kind, s.query, s.actor_ref,
s.state, s.last_error, s.last_polled_at, s.next_poll_at, s.consecutive_failures
FROM column_sources s WHERE s.workspace_id = $1`;
function sourceFromRow(row: Record<string, unknown>): ColumnSourceView {
return {
id: String(row.id),
columnId: String(row.column_id),
provider: String(row.provider),
connectionId: row.connection_id === null || row.connection_id === undefined ? null : String(row.connection_id),
sourceKind: row.source_kind as ColumnSourceKind,
query: row.query === null || row.query === undefined ? null : String(row.query),
actorRef: row.actor_ref === null || row.actor_ref === undefined ? null : String(row.actor_ref),
state: row.state as ColumnSourceState,
lastError: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
lastPolledAt: isoOrNull(row.last_polled_at),
nextPollAt: isoOrNull(row.next_poll_at),
consecutiveFailures: Number(row.consecutive_failures ?? 0),
};
}
function isoOrNull(value: unknown): string | null {
return value === null || value === undefined ? null : (value as Date).toISOString();
}
export interface ColumnStoreDependencies {
/** Resolve a connection's provider for platform_feed source rows. */
providerOf(workspaceId: string, connectionId: string): Promise<string>;
/** Feed-capable connection summaries — the lazy seeding input. */
connections(workspaceId: string): Promise<ConnectionSummary[]>;
}
export const COLUMN_STORE_DEPENDENCIES = Symbol("COLUMN_STORE_DEPENDENCIES");
@Injectable()
export class ColumnStore {
constructor(@Optional() @Inject(COLUMN_STORE_DEPENDENCIES) private readonly deps?: ColumnStoreDependencies) {}
private async providerOf(workspaceId: string, connectionId: string): Promise<string> {
if (this.deps) return this.deps.providerOf(workspaceId, connectionId);
return withTenant(workspaceId, async (client): Promise<string> => {
const result = await client.query<{ provider: string }>(
"SELECT provider FROM connections WHERE id = $1 AND workspace_id = $2 AND disconnected_at IS NULL", [connectionId, workspaceId]);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`connection ${connectionId} not found`);
return row.provider;
});
}
private async connections(workspaceId: string): Promise<ConnectionSummary[]> {
if (this.deps) return this.deps.connections(workspaceId);
const { PgConnectionStore } = await import("../connections/connection-store.js");
return new PgConnectionStore().list(workspaceId);
}
async list(workspaceId: string): Promise<ColumnRecord[]> {
return withTenant(workspaceId, async (client) => {
const result = await client.query(
`SELECT ${COLUMN_SELECT} FROM stream_columns WHERE deleted_at IS NULL ORDER BY position, created_at`);
return result.rows.map((row) => toRecord(row as ColumnRow));
});
}
async get(workspaceId: string, id: string): Promise<ColumnRecord> {
return withTenant(workspaceId, async (client) => this.read(client, workspaceId, id));
}
async sources(workspaceId: string, columnId?: string): Promise<ColumnSourceView[]> {
return withTenant(workspaceId, async (client) => {
const result = columnId === undefined
? await client.query(`${SOURCE_SELECT} ORDER BY s.created_at`, [workspaceId])
: await client.query(`${SOURCE_SELECT} AND s.column_id = $2 ORDER BY s.created_at`, [workspaceId, columnId]);
return result.rows.map((row) => sourceFromRow(row));
});
}
async create(session: SessionPayload, input: CreateColumn): Promise<ColumnRecord> {
const spec = mapValidationError(() => parseStreamColumnSpec(input.type, input.spec));
const policy = parseRefreshPolicyClamped(input.refreshPolicy ?? {});
const title = clampTitle(input.title ?? "");
if (title === "") throw new UnprocessableEntityException("title must be 1..80 characters after trimming");
return withTenant(session.workspaceId, async (client) => {
await this.assertSpecReferenceable(client, session.workspaceId, input.type, spec);
const position = input.position === undefined
? await this.nextPosition(client, session.workspaceId)
: clampPosition(input.position);
const result = await client.query(
`INSERT INTO stream_columns (workspace_id, type, title, position, spec, refresh_policy, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLUMN_SELECT}`,
[session.workspaceId, input.type, title, position, JSON.stringify(spec), JSON.stringify(policy), session.userId],
);
const record = toRecord(result.rows[0] as ColumnRow);
await this.writeSources(client, session.workspaceId, record.id, input.type, spec);
await appendTenantAudit(session.workspaceId, {
actorUserId: session.userId, actorRole: session.role, action: "columns.created",
targetType: "stream_column", targetId: record.id, diff: { type: input.type, title },
});
return record;
});
}
async update(session: SessionPayload, id: string, input: UpdateColumn): Promise<ColumnRecord> {
if (Object.keys(input).length === 0) throw new BadRequestException("no recognized fields to update");
return withTenant(session.workspaceId, async (client) => {
await client.query("SELECT id FROM stream_columns WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE", [session.workspaceId, id]);
const current = await this.read(client, session.workspaceId, id);
const title = input.title === undefined ? current.title : clampTitle(input.title);
if (title === "") throw new UnprocessableEntityException("title must be 1..80 characters after trimming");
// Column type is immutable (design §1.4: PATCH carries title/position/
// spec/refresh_policy); a different type is a new column.
const spec = input.spec === undefined ? current.spec : mapValidationError(() => parseStreamColumnSpec(current.type, input.spec));
const policy = input.refreshPolicy === undefined ? current.refreshPolicy : parseRefreshPolicyClamped(input.refreshPolicy);
const position = input.position === undefined ? current.position : clampPosition(input.position);
if (input.spec !== undefined) await this.assertSpecReferenceable(client, session.workspaceId, current.type, spec);
const result = await client.query(
`UPDATE stream_columns SET title = $3, position = $4, spec = $5, refresh_policy = $6, updated_at = now()
WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING ${COLUMN_SELECT}`,
[session.workspaceId, id, title, position, JSON.stringify(spec), JSON.stringify(policy)],
);
if (result.rows[0] === undefined) throw new NotFoundException(`column ${id} not found`);
if (input.spec !== undefined) await this.rewriteSources(client, session.workspaceId, id, current.type, spec);
await appendTenantAudit(session.workspaceId, {
actorUserId: session.userId, actorRole: session.role, action: "columns.updated",
targetType: "stream_column", targetId: id,
diff: { titleChanged: title !== current.title, positionChanged: position !== current.position, specChanged: input.spec !== undefined },
});
return toRecord(result.rows[0] as ColumnRow);
});
}
private async writeSources(client: PoolClient, workspaceId: string, columnId: string, type: StreamColumnType, spec: StreamColumnSpec): Promise<void> {
for (const identity of sourceIdentities(type, spec)) {
const provider = identity.provider !== "" ? identity.provider : await this.providerOf(workspaceId, identity.connectionId ?? "");
await client.query(
`INSERT INTO column_sources (workspace_id, column_id, provider, connection_id, source_kind, query, actor_ref)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (workspace_id, column_id, provider, source_kind, connection_id, query, actor_ref) DO NOTHING`,
[workspaceId, columnId, provider, identity.connectionId, identity.sourceKind, identity.query, identity.actorRef],
);
}
}
/** Spec identity changes rewrite the source set; identities no longer referenced by this column are dropped. */
private async rewriteSources(client: PoolClient, workspaceId: string, columnId: string, type: StreamColumnType, spec: StreamColumnSpec): Promise<void> {
const wanted = new Set(sourceIdentities(type, spec).map(identityKey));
const existing = await client.query<{ id: string; provider: string; connection_id: string | null; source_kind: ColumnSourceKind; query: string | null; actor_ref: string | null }>(
"SELECT id, provider, connection_id, source_kind, query, actor_ref FROM column_sources WHERE workspace_id = $1 AND column_id = $2", [workspaceId, columnId]);
for (const row of existing.rows) {
const identity: SourceIdentity = { provider: row.provider, connectionId: row.connection_id, sourceKind: row.source_kind, query: row.query, actorRef: row.actor_ref };
if (!wanted.has(identityKey(identity))) {
await client.query("DELETE FROM column_sources WHERE workspace_id = $1 AND id = $2", [workspaceId, row.id]);
}
}
await this.writeSources(client, workspaceId, columnId, type, spec);
}
async remove(session: SessionPayload, id: string): Promise<void> {
await withTenant(session.workspaceId, async (client) => {
await client.query("SELECT id FROM stream_columns WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE", [session.workspaceId, id]);
const result = await client.query(
"UPDATE stream_columns SET deleted_at = now(), updated_at = now() WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL RETURNING id", [session.workspaceId, id]);
if (result.rows[0] === undefined) throw new NotFoundException(`column ${id} not found`);
await appendTenantAudit(session.workspaceId, {
actorUserId: session.userId, actorRole: session.role, action: "columns.deleted",
targetType: "stream_column", targetId: id,
});
});
}
/**
* Explicit order (design §1.4 POST /reorder): the client sends the full
* ordered id list; positions are rewritten densely. Unknown, foreign or
* missing ids are a 400 — a partial reorder would corrupt the order.
*/
async reorder(session: SessionPayload, ids: string[]): Promise<ColumnRecord[]> {
if (ids.length === 0) return this.list(session.workspaceId);
if (new Set(ids).size !== ids.length) throw new BadRequestException("reorder ids must be unique");
return withTenant(session.workspaceId, async (client) => {
const existing = await client.query<{ id: string }>(
"SELECT id FROM stream_columns WHERE workspace_id = $1 AND deleted_at IS NULL", [session.workspaceId]);
if (ids.length !== existing.rows.length || !existing.rows.every((row) => ids.includes(row.id))) {
throw new BadRequestException("reorder must list every column in the workspace exactly once");
}
// Two-phase write: the unique (workspace_id, position) index would fire
// mid-swap if positions were assigned in place (e.g. moving column 0 to
// the end collides with the row already at that slot). Parking every row
// at a high negative offset first leaves the final range untouched, so
// the second pass can never collide. Offsets are outside the CHECK
// (position >= 0) only transiently... so instead park at (n + i), which
// is >= 0, unique within the transaction, and freed before phase two.
// Phase 1 — park every row at a unique high slot (n, n+1, …) so the
// unique (workspace_id, position) index can't fire mid-reorder.
let park = ids.length;
for (const id of ids) {
await client.query("UPDATE stream_columns SET position = $3 WHERE workspace_id = $1 AND id = $2", [session.workspaceId, id, park]);
park += 1;
}
// Phase 2 — assign final dense ranks in the REQUESTED order. The parked
// range is disjoint from [0, n), so no intermediate state collides.
const byId = new Map(ids.map((id, index) => [id, index] as const));
const current = await client.query<ColumnRow>(
`SELECT ${COLUMN_SELECT} FROM stream_columns WHERE workspace_id = $1 AND deleted_at IS NULL`, [session.workspaceId]);
const records = current.rows
.map((row) => toRecord(row as ColumnRow))
.sort((a, b) => (byId.get(a.id) ?? 0) - (byId.get(b.id) ?? 0));
let rank = 0;
for (const record of records) {
await client.query("UPDATE stream_columns SET position = $3, updated_at = now() WHERE workspace_id = $1 AND id = $2", [session.workspaceId, record.id, rank]);
record.position = rank;
rank += 1;
}
await appendTenantAudit(session.workspaceId, {
actorUserId: session.userId, actorRole: session.role, action: "columns.reordered",
targetType: "workspace", targetId: session.workspaceId, diff: { count: ids.length },
});
return records;
});
}
/**
* Lazy seeding (design §6 P1): on the first column-API touch for a workspace
* with no columns at all, materialize TODAY's implicit /feeds columns — one
* platform_feed column per feed-capable connection, titled exactly like the
* implicit column the UI renders today ("Display · handle", kinds ["home"],
* position by connection creation order), so the Streams page is unchanged.
* Idempotent by construction: a second call sees a non-empty column list and
* seeds nothing — including for connections added later.
*/
async ensureSeeded(workspaceId: string, connections: ConnectionSummary[], capable: (row: ConnectionSummary) => boolean): Promise<{ seeded: number }> {
const rows = connections.filter(capable);
if (rows.length === 0) return { seeded: 0 };
return withTenant(workspaceId, async (client) => {
// Serialize concurrent first touches without taking a row lock on
// workspaces itself: a FOR UPDATE here deadlocks with any FK insert
// into audit_log/column_sources from a nested withTenant client,
// because FK validation takes FOR KEY SHARE on the parent workspace
// row. Advisory xact locks never block key-share, so seeding
// serializes while audits and FK checks proceed.
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`columns-seed:${workspaceId}`]);
const existing = await client.query<{ count: string }>(
"SELECT count(*)::text AS count FROM stream_columns WHERE workspace_id = $1 AND deleted_at IS NULL", [workspaceId]);
if (Number(existing.rows[0]?.count ?? 0) > 0) return { seeded: 0 };
const seeder = await client.query<{ user_id: string }>(
"SELECT m.user_id FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.workspace_id = $1 ORDER BY m.created_at, u.created_at LIMIT 1", [workspaceId]);
const createdBy = seeder.rows[0]?.user_id;
if (createdBy === undefined) return { seeded: 0 }; // no member to attribute
let seeded = 0;
let position = 0;
for (const row of rows) {
const spec = { connectionId: row.id, kinds: ["home"] } as const;
const inserted = await client.query<{ id: string }>(
`INSERT INTO stream_columns (workspace_id, type, title, position, spec, refresh_policy, created_by)
VALUES ($1,'platform_feed',$2,$3,$4,'{}',$5) RETURNING id`,
[workspaceId, seedTitle(row), position, JSON.stringify(spec), createdBy],
);
const columnId = inserted.rows[0]?.id;
if (columnId === undefined) continue;
await client.query(
`INSERT INTO column_sources (workspace_id, column_id, provider, connection_id, source_kind)
VALUES ($1,$2,$3,$4,'home')
ON CONFLICT (workspace_id, column_id, provider, source_kind, connection_id, query, actor_ref) DO NOTHING`,
[workspaceId, columnId, row.provider, row.id],
);
seeded += 1;
position += 1;
}
if (seeded > 0) await appendTenantAudit(workspaceId, {
actorUserId: null, actorRole: "system", action: "columns.seeded",
targetType: "workspace", targetId: workspaceId, diff: { seeded },
});
return { seeded };
});
}
/**
* import-saved-searches (design §1.4): the localStorage saved-searches
* contract moves server-side. Each query becomes a saved_search column over
* the platforms the workspace can actually search, defaulting to every
* connected feed provider when the client sends none. Idempotent per query:
* a live saved_search column with the same query is skipped.
*/
async importSavedSearches(
session: SessionPayload, queries: string[], platforms: string[],
): Promise<{ imported: number; skipped: number }> {
if (platforms.length === 0) return { imported: 0, skipped: queries.length };
return withTenant(session.workspaceId, async (client) => {
let imported = 0;
let skipped = 0;
for (const raw of queries) {
const query = raw.trim();
if (query === "") { skipped += 1; continue; }
const duplicate = await client.query<{ id: string }>(
`SELECT id FROM stream_columns WHERE workspace_id = $1 AND deleted_at IS NULL AND type = 'saved_search'
AND spec->>'query' = $2 LIMIT 1`, [session.workspaceId, query]);
if (duplicate.rows[0] !== undefined) { skipped += 1; continue; }
const spec = { query, platforms, matchMode: "any" } as const;
const title = clampTitle(query);
if (title === "") { skipped += 1; continue; }
const position = await this.nextPosition(client, session.workspaceId);
const inserted = await client.query<{ id: string }>(
`INSERT INTO stream_columns (workspace_id, type, title, position, spec, refresh_policy, created_by)
VALUES ($1,'saved_search',$2,$3,$4,'{}',$5) RETURNING id`,
[session.workspaceId, title, position, JSON.stringify(spec), session.userId],
);
const columnId = inserted.rows[0]?.id;
if (columnId === undefined) { skipped += 1; continue; }
await this.writeSources(client, session.workspaceId, columnId, "saved_search",
parseStreamColumnSpec("saved_search", spec));
imported += 1;
}
if (imported > 0) await appendTenantAudit(session.workspaceId, {
actorUserId: session.userId, actorRole: session.role, action: "columns.saved_searches_imported",
targetType: "workspace", targetId: session.workspaceId, diff: { imported, skipped },
});
return { imported, skipped };
});
}
/**
* Poll-time item persistence (§3.3): first writer wins per dedup key (the
* cross-column dedup), then fan-out rows link the item to every column
* whose source shares this source's identity — which is how "same account
* in two columns costs one poll" still surfaces items in both.
*/
async storeItems(workspaceId: string, sourceId: string, items: NormalizedFeedItem[]): Promise<{ stored: number; fanned: number }> {
if (items.length === 0) return { stored: 0, fanned: 0 };
return withTenant(workspaceId, async (client) => {
const found = await client.query<{ id: string; provider: string; connection_id: string | null; source_kind: ColumnSourceKind; query: string | null; actor_ref: string | null }>(
"SELECT id, provider, connection_id, source_kind, query, actor_ref FROM column_sources WHERE workspace_id = $1 AND id = $2", [workspaceId, sourceId]);
const source = found.rows[0];
if (source === undefined) return { stored: 0, fanned: 0 };
const sharing = await client.query<{ column_id: string }>(
`SELECT column_id FROM column_sources
WHERE workspace_id = $1 AND provider = $2 AND source_kind = $3
AND connection_id IS NOT DISTINCT FROM $4 AND query IS NOT DISTINCT FROM $5 AND actor_ref IS NOT DISTINCT FROM $6
AND state <> 'disabled'`,
[workspaceId, source.provider, source.source_kind, source.connection_id, source.query, source.actor_ref]);
const columnIds = sharing.rows.map((row) => row.column_id);
let stored = 0;
let fanned = 0;
for (const item of items) {
const dedupKey = dedupKeyFor(workspaceId, item.provider, item.remoteId);
// First writer wins per dedup key (the workspace-wide cross-column
// dedup, §3.3) — the returning row resolves the item id either way.
const inserted = await client.query<{ id: string }>(
`INSERT INTO feed_items (workspace_id, dedup_key, provider, remote_id, item_id, author_handle, author_ref, url,
created_at_remote, fetched_at, content, plain_text, media, metrics, source_id, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,now(),$10,$11,$12,$13,$14,$15)
ON CONFLICT (workspace_id, dedup_key) DO UPDATE SET fetched_at = feed_items.fetched_at RETURNING id`,
[workspaceId, dedupKey, item.provider, item.remoteId, item.id, item.authorHandle, item.authorRef, item.url,
item.createdAt.toISOString(), JSON.stringify(item.content), item.plainText, JSON.stringify(item.media),
JSON.stringify(item.metrics), sourceId, item.expiresAt === null || item.expiresAt === undefined ? null : item.expiresAt.toISOString()],
);
const itemId = inserted.rows[0]?.id;
if (itemId === undefined) continue;
stored += 1;
// Fan out regardless of who inserted: an item ingested for one source
// identity is visible to EVERY column sharing that identity, including
// items an earlier poll already stored (§3.3) — otherwise the second
// column's dedup-hit items would never get their link rows.
for (const columnId of columnIds) {
const linked = await client.query(
"INSERT INTO feed_item_columns (workspace_id, item_id, column_id) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING",
[workspaceId, itemId, columnId]);
fanned += linked.rowCount ?? 0;
}
}
return { stored, fanned };
});
}
/**
* Keyset page for GET /feeds/columns/:id/items (§1.4). Cursor rule:
* (created_at_remote DESC, dedup_key DESC) — a pure position in a total
* order, so paging survives cache flushes and later insertions alike.
*/
async columnItems(
workspaceId: string, columnId: string, options: { cursor?: { at: number; key: string }; limit: number },
): Promise<{ items: StoredFeedItem[]; nextCursor: string | null }> {
return withTenant(workspaceId, async (client) => {
const column = await client.query("SELECT id FROM stream_columns WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL", [workspaceId, columnId]);
if (column.rows[0] === undefined) throw new NotFoundException(`column ${columnId} not found`);
const params: unknown[] = [workspaceId, columnId];
const filter = options.cursor === undefined ? "" : " AND (i.created_at_remote, i.dedup_key) < ($3::timestamptz, $4)";
if (options.cursor !== undefined) params.push(new Date(options.cursor.at).toISOString(), options.cursor.key);
params.push(options.limit + 1);
const result = await client.query(
`SELECT i.id, i.dedup_key, i.created_at_remote, i.provider, i.remote_id, i.item_id, i.author_handle,
i.author_ref, i.url, i.content, i.plain_text, i.media, i.metrics, i.expires_at
FROM feed_items i
JOIN feed_item_columns fic ON fic.item_id = i.id AND fic.column_id = $2
WHERE i.workspace_id = $1${filter}
ORDER BY i.created_at_remote DESC, i.dedup_key DESC
LIMIT $${params.length}`,
params,
);
const rows = result.rows;
const hasMore = rows.length > options.limit;
const items = (hasMore ? rows.slice(0, options.limit) : rows).map((row) => storedItemFromRow(row));
const last = items[items.length - 1];
return {
items,
nextCursor: hasMore && last
? Buffer.from(JSON.stringify({ at: Date.parse(last.createdAtRemote), key: last.dedupKey })).toString("base64url")
: null,
};
});
}
private async nextPosition(client: PoolClient, workspaceId: string): Promise<number> {
const result = await client.query<{ max: number | null }>(
"SELECT max(position) AS max FROM stream_columns WHERE workspace_id = $1 AND deleted_at IS NULL", [workspaceId]);
return (result.rows[0]?.max ?? -1) + 1;
}
/** A spec's references must exist in the workspace before the column can persist. */
private async assertSpecReferenceable(client: PoolClient, workspaceId: string, type: StreamColumnType, spec: StreamColumnSpec): Promise<void> {
if (type === "platform_feed") {
const connectionId = (spec as import("@tactical-media-broadcast-hub/core").PlatformFeedSpec).connectionId;
const result = await client.query(
"SELECT 1 FROM connections WHERE workspace_id = $1 AND id = $2 AND disconnected_at IS NULL", [workspaceId, connectionId]);
if (result.rows[0] === undefined) throw new UnprocessableEntityException("platform_feed: connectionId does not reference a connection in this workspace");
return;
}
const providers = columnProviders(type, spec);
if (providers.length === 0) return;
const result = await client.query<{ provider: string }>(
"SELECT DISTINCT provider FROM connections WHERE workspace_id = $1 AND disconnected_at IS NULL AND provider = ANY($2)", [workspaceId, providers]);
const present = new Set(result.rows.map((row) => row.provider));
const missing = [...new Set(providers)].filter((provider) => !present.has(provider));
if (missing.length > 0) throw new UnprocessableEntityException(`no connection for provider(s): ${missing.join(", ")}`);
}
private async read(client: PoolClient, workspaceId: string, id: string): Promise<ColumnRecord> {
const result = await client.query(
`SELECT ${COLUMN_SELECT} FROM stream_columns WHERE workspace_id = $1 AND id = $2 AND deleted_at IS NULL`, [workspaceId, id]);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`column ${id} not found`);
return toRecord(row as ColumnRow);
}
}
interface FeedItemRow {
id: string; dedup_key: string; created_at_remote: Date; provider: string; remote_id: string; item_id: string;
author_handle: string; author_ref: string | null; url: string | null; content: unknown; plain_text: string;
media: unknown; metrics: unknown; expires_at: Date | null;
}
function storedItemFromRow(row: FeedItemRow): StoredFeedItem {
const metrics = (typeof row.metrics === "object" && row.metrics !== null ? row.metrics : {}) as Record<string, number>;
const item: NormalizedFeedItem = {
id: row.item_id,
remoteId: row.remote_id,
provider: row.provider,
// The store page does not carry a live connection id; the column's source
// rows hold it, so the connectionId field is filled by the caller when it
// matters (the items endpoint derives it from the column spec).
connectionId: "",
authorHandle: row.author_handle,
authorRef: row.author_ref,
url: row.url,
text: row.plain_text,
content: row.content as NormalizedFeedItem["content"],
plainText: row.plain_text,
createdAt: row.created_at_remote,
mediaUrls: [],
media: Array.isArray(row.media) ? row.media as NormalizedFeedItem["media"] : [],
metrics,
expiresAt: row.expires_at,
};
return { id: row.id, dedupKey: row.dedup_key, createdAtRemote: new Date(row.created_at_remote).toISOString(), item };
}