Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
893 changes: 893 additions & 0 deletions docs/sparql-to-ad4m-model-migration.md

Large diffs are not rendered by default.

24 changes: 15 additions & 9 deletions packages/api/src/channel/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ function createMockPerspective(querySparqlImpl?: (...args: any[]) => any) {
return {
sparqlCalls,
querySparql: vi.fn(impl),
// Default to an empty modelQuery result so call sites that converted
// off querySparql (e.g. `Channel.pinnedConversations()` post-#846) get
// back `[]` for the empty case without each test having to opt in.
modelQuery: vi.fn().mockResolvedValue({ instances: [], totalCount: 0 }),
get: vi.fn().mockResolvedValue([]),
add: vi.fn().mockResolvedValue({}),
};
Expand Down Expand Up @@ -121,29 +125,31 @@ describe('Channel.pinnedConversations()', () => {

it('returns mapped results', async () => {
const perspective = createMockPerspective();
perspective.querySparql.mockResolvedValueOnce([
{ channelId: 'ch-1', conversationId: 'conv-1' },
]);
perspective.modelQuery.mockResolvedValueOnce({
instances: [{ id: 'ch-1', conversations: [{ id: 'conv-1' }] }],
totalCount: 1,
});

const results = await Channel.pinnedConversations(perspective as any);
expect(results).toHaveLength(1);
expect(results[0]).toEqual({ channelId: 'ch-1', conversationId: 'conv-1' });
});

it('deduplicates by channelId', async () => {
it('handles channels with no linked conversation', async () => {
const perspective = createMockPerspective();
perspective.querySparql.mockResolvedValueOnce([
{ channelId: 'ch-1', conversationId: 'conv-1' },
{ channelId: 'ch-1', conversationId: 'conv-2' },
]);
perspective.modelQuery.mockResolvedValueOnce({
instances: [{ id: 'ch-1', conversations: [] }],
totalCount: 1,
});

const results = await Channel.pinnedConversations(perspective as any);
expect(results).toHaveLength(1);
expect(results[0]).toEqual({ channelId: 'ch-1', conversationId: undefined });
});

it('handles errors gracefully', async () => {
const perspective = createMockPerspective();
perspective.querySparql.mockRejectedValueOnce(new Error('SPARQL error'));
perspective.modelQuery.mockRejectedValueOnce(new Error('modelQuery error'));

const results = await Channel.pinnedConversations(perspective as any);
expect(results).toEqual([]);
Expand Down
46 changes: 21 additions & 25 deletions packages/api/src/channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,36 +349,32 @@ export class Channel extends Ad4mModel {

/**
* Get pinned conversation channels.
* Single SPARQL query — replaces iterative channel.get({ conversations: true }).
*
* Converted from raw SPARQL → `Channel.findAll` post-#846. The query
* shape (`isPinned == true`, optional child conversation) is a textbook
* Category B candidate from `docs/sparql-to-ad4m-model-migration.md`:
* a `Where` on a scalar plus a HasMany include with `limit: 1`.
*
* Trade-off vs the raw SPARQL: the executor still has to fan out one
* include sub-query for the `conversations` relation, but with
* `withMetadata: false` the per-row reifier-metadata join is dropped
* and the round-trip count remains 2 (main + include) — same as the
* old `querySparql` + `OPTIONAL` block.
*/
static async pinnedConversations(
perspective: PerspectiveProxy,
): Promise<{ channelId: string; conversationId?: string }[]> {
const sparql = `
SELECT ?channelId ?conversationId WHERE {
?channelId <${ENTRY_TYPE}> <${EntryType.Channel}> .
?channelId <${CHANNEL_IS_PINNED}> ?_isPinned .
FILTER(STR(<ad4m://fn/parse_literal>(?_isPinned)) = "true")
OPTIONAL {
?channelId <ad4m://has_child> ?conversationId .
?conversationId <flux://entry_type> <flux://conversation> .
}
}
`;

try {
const results = await perspective.querySparql<PinnedConversationBinding[]>(sparql);
// Deduplicate by channelId
const seen = new Map<string, { channelId: string; conversationId?: string }>();
for (const r of results || []) {
const cid = r.channelId;
if (!cid || seen.has(cid)) continue;
seen.set(cid, {
channelId: cid,
conversationId: r.conversationId || undefined,
});
}
return Array.from(seen.values());
const pinned = await Channel.findAll(perspective, {
where: { isPinned: true },
include: { conversations: { limit: 1, withMetadata: false } },
withMetadata: false,
count: false,
});
return pinned.map((ch) => ({
channelId: ch.id,
conversationId: ch.conversations?.[0]?.id || undefined,
}));
} catch (error) {
console.error('Error in Channel.pinnedConversations():', error);
return [];
Expand Down
52 changes: 26 additions & 26 deletions packages/api/src/conversation-subgroup/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Model, Ad4mModel, Flag, HasMany, Property, Literal, parseLit } from '@coasys/ad4m';
import { Model, Ad4mModel, Flag, HasMany, LinkQuery, Property, Literal, parseLit } from '@coasys/ad4m';
import Topic, { TopicWithRelevance } from '../topic';
import SemanticRelationship from '../semantic-relationship';
import { SynergyTopic, SynergyItem, ItemType, icons } from '@coasys/flux-utils';
Expand All @@ -7,8 +7,6 @@ import { community } from '@coasys/flux-constants';
const { FLUX_PARTICIPANT, SUBGROUP_ITEM } = community;

// SPARQL binding shapes — typed via `querySparql<T>()`.
interface ItemIdBinding { item: string }
interface DidBinding { did: string }
interface TopicBinding { topicBase: string; topicNameRaw?: string }
interface TopicRelevanceBinding extends TopicBinding { relevanceRaw?: string }
interface SubgroupItemBinding {
Expand Down Expand Up @@ -47,31 +45,33 @@ export default class ConversationSubgroup extends Ad4mModel {
participants: string[] = [];

async stats(): Promise<{ totalItems: number; participants: string[] }> {
// find the total item count and the dids of participants in the subgroup
// Converted from two parallel SPARQLs to a single parallel block of
// (a) one indexed `queryLinks` to enumerate subgroup→item links and
// (b) one indexed `queryLinks` for participants. Both bypass SPARQL
// entirely — the historical SPARQL itemsQuery did a multi-type
// `FILTER(?type IN (...))` which can't be pushed into a single
// Ad4mModel query without three parallel `findAllAndCount` calls
// and a sum. Using indexed link lookup avoids that round-trip
// multiplication while keeping the data shape identical to the
// original query (the subgroup→item link target is always a
// Message/Post/Task by Flux invariant).
try {
// SPARQL migration
const itemsQuery = `
SELECT DISTINCT ?item WHERE {
<${this.id}> <${SUBGROUP_ITEM}> ?item .
?item <flux://entry_type> ?type .
FILTER(?type IN (<flux://has_message>, <flux://has_post>, <flux://has_task>))
}
`;

// Use targeted SPARQL for participants instead of this.get() which fetches all triples
const participantsQuery = `
SELECT ?did WHERE {
<${this.id}> <${FLUX_PARTICIPANT}> ?did .
}
`;

const [itemsResult, participantsResult] = await Promise.all([
this.perspective.querySparql<ItemIdBinding[]>(itemsQuery),
this.perspective.querySparql<DidBinding[]>(participantsQuery),
const [itemLinks, participantLinks] = await Promise.all([
this.perspective.get(
new LinkQuery({ source: this.id, predicate: SUBGROUP_ITEM }),
),
this.perspective.get(
new LinkQuery({ source: this.id, predicate: FLUX_PARTICIPANT }),
),
]);

const totalItems = itemsResult?.length || 0;
const participants = (participantsResult || []).map((r) => r.did).filter(Boolean);
const totalItems = new Set(
(itemLinks || [])
.map((l: any) => l.data?.target)
.filter(Boolean),
).size;
const participants = (participantLinks || [])
.map((l: any) => l.data?.target)
.filter(Boolean);
return { totalItems, participants };
} catch (error) {
console.error('Error getting subgroup stats:', error);
Expand Down
59 changes: 42 additions & 17 deletions packages/api/src/conversation/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ vi.mock('./util', () => ({

// Import after mocks are hoisted
import { Conversation } from './index';
import ConversationSubgroup from '../conversation-subgroup';

// ---------------------------------------------------------------------------
// Helpers
Expand All @@ -143,6 +144,10 @@ function createMockPerspective(querySparqlImpl?: (...args: any[]) => any) {
sparqlCalls,
addLinksCalls,
querySparql: vi.fn(impl),
// Default to an empty modelQuery result — required since
// `Conversation.stats()` post-#846 calls
// `ConversationSubgroup.findAllAndCount(...)` instead of raw SPARQL.
modelQuery: vi.fn().mockResolvedValue({ instances: [], totalCount: 0 }),
get: vi.fn().mockResolvedValue([]),
add: vi.fn().mockResolvedValue({}),
addLinks: vi.fn(async (...args: any[]) => {
Expand Down Expand Up @@ -188,17 +193,24 @@ function createTranscribedItems() {
// ---------------------------------------------------------------------------

describe('Conversation.stats()', () => {
// After the #846 SPARQL→Ad4mModel migration, stats() now calls
// `ConversationSubgroup.findAllAndCount` for the subgroup count and
// `perspective.get(LinkQuery)` for participants. We stub
// `findAllAndCount` directly because the conversation.test.ts vi.mock
// for `@coasys/ad4m` provides a stripped-down `@Model` decorator that
// doesn't register the metadata `Ad4mModel.getModelMetadata()` reads
// — running the real `findAllAndCount` path against the test mock
// would silently fail in the catch block.

it('returns correct subgroup count and participants from query results', async () => {
let callCount = 0;
const perspective = createMockPerspective(async () => {
callCount++;
if (callCount === 1) {
// subgroups query
return [{ sg: 'sg-1' }, { sg: 'sg-2' }, { sg: 'sg-3' }];
}
// participants query
return [{ did: 'did:test:alice' }, { did: 'did:test:bob' }];
});
const perspective = createMockPerspective();
perspective.get.mockResolvedValueOnce([
{ data: { target: 'did:test:alice' } },
{ data: { target: 'did:test:bob' } },
]);
const findAllAndCountSpy = vi
.spyOn(ConversationSubgroup, 'findAllAndCount')
.mockResolvedValueOnce({ results: [], totalCount: 3 } as any);
const conv = new Conversation(perspective as any, 'conv-1');
conv.get = vi.fn().mockResolvedValue(undefined);
conv.participants = [];
Expand All @@ -207,43 +219,56 @@ describe('Conversation.stats()', () => {

expect(stats.totalSubgroups).toBe(3);
expect(stats.participants).toEqual(['did:test:alice', 'did:test:bob']);
findAllAndCountSpy.mockRestore();
});

it('filters out null/undefined participant dids', async () => {
let callCount = 0;
const perspective = createMockPerspective(async () => {
callCount++;
if (callCount === 1) return [];
return [{ did: 'did:test:alice' }, { did: null }, { did: undefined }, { did: 'did:test:bob' }];
});
const perspective = createMockPerspective();
perspective.get.mockResolvedValueOnce([
{ data: { target: 'did:test:alice' } },
{ data: { target: null } },
{ data: { target: undefined } },
{ data: { target: 'did:test:bob' } },
]);
const findAllAndCountSpy = vi
.spyOn(ConversationSubgroup, 'findAllAndCount')
.mockResolvedValueOnce({ results: [], totalCount: 0 } as any);
const conv = new Conversation(perspective as any, 'conv-1');
conv.get = vi.fn().mockResolvedValue(undefined);
conv.participants = [];

const stats = await conv.stats();

expect(stats.participants).toEqual(['did:test:alice', 'did:test:bob']);
findAllAndCountSpy.mockRestore();
});

it('returns zero subgroups when query returns empty', async () => {
const perspective = createMockPerspective();
const findAllAndCountSpy = vi
.spyOn(ConversationSubgroup, 'findAllAndCount')
.mockResolvedValueOnce({ results: [], totalCount: 0 } as any);
const conv = new Conversation(perspective as any, 'conv-1');
conv.get = vi.fn().mockResolvedValue(undefined);
conv.participants = [];

const stats = await conv.stats();
expect(stats.totalSubgroups).toBe(0);
expect(stats.participants).toEqual([]);
findAllAndCountSpy.mockRestore();
});

it('handles query errors gracefully', async () => {
const perspective = createMockPerspective();
perspective.querySparql.mockRejectedValueOnce(new Error('SPARQL error'));
const findAllAndCountSpy = vi
.spyOn(ConversationSubgroup, 'findAllAndCount')
.mockRejectedValueOnce(new Error('modelQuery error'));
const conv = new Conversation(perspective as any, 'conv-1');

const stats = await conv.stats();
expect(stats.totalSubgroups).toBe(0);
expect(stats.participants).toEqual([]);
findAllAndCountSpy.mockRestore();
});
});

Expand Down
46 changes: 22 additions & 24 deletions packages/api/src/conversation/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Ad4mModel, Ad4mClient, Flag, HasMany, HasManyMethods, Link, Literal, Model, Property, parseLit } from '@coasys/ad4m';
import { Ad4mModel, Ad4mClient, Flag, HasMany, HasManyMethods, Link, LinkQuery, Literal, Model, Property, parseLit } from '@coasys/ad4m';

import { getProfile, Topic } from '@coasys/flux-api';
import { ProcessingState, Profile } from '@coasys/flux-types';
Expand All @@ -12,8 +12,6 @@ import { community } from '@coasys/flux-constants';
const { FLUX_PARTICIPANT, SUBGROUP_ITEM } = community;

// SPARQL binding shapes — typed via `querySparql<T>()`.
interface SgBinding { sg: string }
interface DidBinding { did: string }
interface TopicBinding { topicBase: string; topicNameRaw?: string }
interface SubgroupRowBinding {
id: string;
Expand Down Expand Up @@ -54,29 +52,29 @@ export class Conversation extends Ad4mModel {
subgroupEntities: ConversationSubgroup[] = [];

async stats(): Promise<{ totalSubgroups: number; participants: string[] }> {
// find the total subgroup count and the dids of participants in the conversation
// Converted from two parallel SPARQLs → one `findAllAndCount` count-only
// path + one native `LinkQuery` (post-#846). The subgroup count engages
// the count-only fast path (`limit: 0` + `count: true`) so the executor
// emits a single `SELECT (COUNT(DISTINCT ?source) AS ?cnt) WHERE { ... }`
// — no instance hydration, no reifier-metadata join. The participants
// list bypasses SPARQL entirely via the indexed `queryLinks` path, which
// is faster than the historical SPARQL on small/medium result sets
// (confirmed by wind tunnel S5 vs S8).
try {
// SPARQL migration
const subgroupsQuery = `
SELECT ?sg WHERE {
<${this.id}> <ad4m://has_child> ?sg .
?sg <flux://entry_type> <flux://conversation_subgroup> .
}
`;

const participantsQuery = `
SELECT ?did WHERE {
<${this.id}> <${FLUX_PARTICIPANT}> ?did .
}
`;

const [subgroupsResult, participantsResult] = await Promise.all([
this.perspective.querySparql<SgBinding[]>(subgroupsQuery),
this.perspective.querySparql<DidBinding[]>(participantsQuery),
const [{ totalCount: totalSubgroups }, participantLinks] = await Promise.all([
ConversationSubgroup.findAllAndCount(this.perspective, {
parent: { model: Conversation, id: this.id },
limit: 0,
count: true,
withMetadata: false,
}),
this.perspective.get(
new LinkQuery({ source: this.id, predicate: FLUX_PARTICIPANT }),
),
]);

const totalSubgroups = subgroupsResult?.length || 0;
const participants = (participantsResult || []).map((r) => r.did).filter(Boolean);
const participants = (participantLinks || [])
.map((l: any) => l.data?.target)
.filter(Boolean);
return { totalSubgroups, participants };
} catch (error) {
console.error('Error getting conversation stats:', error);
Expand Down
Loading
Loading