Skip to content

Commit 8563613

Browse files
Release v0.3.4.
Fix retrieve_entities task pagination/count consistency so deleted-filtered entities do not inflate totals and task pages return stable, complete results across offsets.
1 parent dd630dc commit 8563613

File tree

5 files changed

+181
-39
lines changed

5 files changed

+181
-39
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "neotoma",
3-
"version": "0.3.3",
3+
"version": "0.3.4",
44
"description": "MCP server for structured personal data memory with unified source ingestion",
55
"main": "dist/index.js",
66
"type": "module",

src/services/entity_queries.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function queryEntities(
6060
// Build query for entities
6161
let entityQuery = db
6262
.from("entities")
63-
.select("id, entity_type, canonical_name, user_id, merged_to_entity_id, merged_at");
63+
.select("id, entity_type, canonical_name, user_id, merged_to_entity_id, merged_at, created_at");
6464

6565
// Filter by user if provided
6666
if (userId) {
@@ -82,38 +82,28 @@ export async function queryEntities(
8282
entityQuery = entityQuery.in("id", filterEntityIds);
8383
}
8484

85-
entityQuery = entityQuery.range(offset, offset + limit - 1);
85+
// Always use a deterministic order so pagination is stable across calls.
86+
entityQuery = entityQuery.order("id", { ascending: true });
8687

87-
const { data: entitiesData, error: entitiesError } = await entityQuery;
88+
const { data: baseEntitiesData, error: entitiesError } = await entityQuery;
8889

8990
if (entitiesError) {
9091
throw new Error(`Failed to query entities: ${entitiesError.message}`);
9192
}
9293

93-
if (!entitiesData || entitiesData.length === 0) {
94+
if (!baseEntitiesData || baseEntitiesData.length === 0) {
9495
return [];
9596
}
9697

97-
// Get snapshots for entities
98-
let entities = entitiesData;
99-
const entityIds = entities.map((e: any) => e.id);
100-
const { data: snapshots, error: snapshotsError } = await db
101-
.from("entity_snapshots")
102-
.select("*")
103-
.in("entity_id", entityIds);
104-
105-
if (snapshotsError) {
106-
throw new Error(`Failed to query snapshots: ${snapshotsError.message}`);
107-
}
108-
109-
// Filter deleted entities unless explicitly requested
110-
let filteredEntityIds = entityIds;
98+
// Filter deleted entities unless explicitly requested, then paginate.
99+
let entities = baseEntitiesData;
111100
if (!includeDeleted) {
101+
const allEntityIds = entities.map((e: any) => e.id);
112102
// Check for deletion observations (highest priority with _deleted: true)
113103
const { data: deletionObservations } = await db
114104
.from("observations")
115105
.select("entity_id, source_priority, observed_at, fields")
116-
.in("entity_id", entityIds)
106+
.in("entity_id", allEntityIds)
117107
.order("source_priority", { ascending: false })
118108
.order("observed_at", { ascending: false });
119109

@@ -144,12 +134,24 @@ export async function queryEntities(
144134
}
145135

146136
// Filter out deleted entities
147-
filteredEntityIds = entityIds.filter((id: string) => !deletedEntityIds.has(id));
148137
entities = entities.filter((e: any) => !deletedEntityIds.has(e.id));
138+
}
149139

150-
if (entities.length === 0) {
151-
return [];
152-
}
140+
entities = entities.slice(offset, offset + limit);
141+
if (entities.length === 0) {
142+
return [];
143+
}
144+
145+
// Get snapshots for entities on the selected page
146+
const entityIds = entities.map((e: any) => e.id);
147+
const filteredEntityIds = entityIds;
148+
const { data: snapshots, error: snapshotsError } = await db
149+
.from("entity_snapshots")
150+
.select("*")
151+
.in("entity_id", entityIds);
152+
153+
if (snapshotsError) {
154+
throw new Error(`Failed to query snapshots: ${snapshotsError.message}`);
153155
}
154156

155157
// Combine entities with snapshots

src/shared/action_handlers/entity_handlers.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,63 @@ interface QueryEntitiesParams {
1414
offset?: number;
1515
}
1616

17+
async function countVisibleEntities(params: {
18+
userId: string;
19+
entityType?: string;
20+
includeMerged?: boolean;
21+
}): Promise<number> {
22+
const { userId, entityType, includeMerged = false } = params;
23+
24+
let entityIdQuery = db.from("entities").select("id").eq("user_id", userId);
25+
if (entityType) {
26+
entityIdQuery = entityIdQuery.eq("entity_type", entityType);
27+
}
28+
if (!includeMerged) {
29+
entityIdQuery = entityIdQuery.is("merged_to_entity_id", null);
30+
}
31+
32+
const { data: entityRows, error: entityError } = await entityIdQuery;
33+
if (entityError) {
34+
throw new Error(`Failed to query entity ids for count: ${entityError.message}`);
35+
}
36+
if (!entityRows || entityRows.length === 0) {
37+
return 0;
38+
}
39+
40+
const entityIds = entityRows.map((row: { id: string }) => row.id);
41+
const deletedEntityIds = new Set<string>();
42+
const chunkSize = 500;
43+
44+
for (let i = 0; i < entityIds.length; i += chunkSize) {
45+
const chunk = entityIds.slice(i, i + chunkSize);
46+
const { data: deletionObservations, error: observationsError } = await db
47+
.from("observations")
48+
.select("entity_id, source_priority, observed_at, fields")
49+
.in("entity_id", chunk)
50+
.order("source_priority", { ascending: false })
51+
.order("observed_at", { ascending: false });
52+
53+
if (observationsError) {
54+
throw new Error(`Failed to query deletion observations for count: ${observationsError.message}`);
55+
}
56+
57+
const highestByEntity = new Map<string, any>();
58+
for (const obs of deletionObservations || []) {
59+
if (!highestByEntity.has(obs.entity_id)) {
60+
highestByEntity.set(obs.entity_id, obs);
61+
}
62+
}
63+
64+
for (const [entityId, obs] of highestByEntity.entries()) {
65+
if (obs.fields?._deleted === true) {
66+
deletedEntityIds.add(entityId);
67+
}
68+
}
69+
}
70+
71+
return entityIds.length - deletedEntityIds.size;
72+
}
73+
1774
export async function queryEntitiesWithCount(params: QueryEntitiesParams): Promise<{
1875
entities: EntityWithProvenance[];
1976
total: number;
@@ -83,19 +140,7 @@ export async function queryEntitiesWithCount(params: QueryEntitiesParams): Promi
83140
offset,
84141
});
85142

86-
let countQuery = db.from("entities").select("*", { count: "exact", head: true });
87-
countQuery = countQuery.eq("user_id", userId);
88-
if (entityType) {
89-
countQuery = countQuery.eq("entity_type", entityType);
90-
}
91-
if (!includeMerged) {
92-
countQuery = countQuery.is("merged_to_entity_id", null);
93-
}
94-
const { count, error: countError } = await countQuery;
95-
if (countError) {
96-
throw new Error(`Failed to count entities: ${countError.message}`);
97-
}
98-
total = count || 0;
143+
total = await countVisibleEntities({ userId, entityType, includeMerged });
99144
}
100145

101146
return {

tests/integration/mcp_entity_variations.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
22
import { db } from "../../src/db.js";
33
import { TestIdTracker } from "../helpers/cleanup_helpers.js";
44
import { verifyEntityExists, computeEntitySnapshot } from "../helpers/database_verifiers.js";
5+
import { queryEntities } from "../../src/services/entity_queries.js";
6+
import { queryEntitiesWithCount } from "../../src/shared/action_handlers/entity_handlers.js";
57

68
describe("MCP entity actions - parameter variations", () => {
79
const tracker = new TestIdTracker();
@@ -212,6 +214,99 @@ describe("MCP entity actions - parameter variations", () => {
212214

213215
expect(entities!.length).toBeGreaterThan(0);
214216
});
217+
218+
it("should paginate after filtering deleted task entities", async () => {
219+
const localUserId = `test-user-entity-pagination-${Date.now()}`;
220+
const sourceId = `src_pagination_${Date.now()}`;
221+
const deletedEntityId = `ent_000_deleted_${Date.now()}`;
222+
const activeEntityId = `ent_999_active_${Date.now()}`;
223+
224+
const { error: sourceError } = await db.from("sources").insert({
225+
id: sourceId,
226+
user_id: localUserId,
227+
content_hash: `pagination_deleted_filter_${Date.now()}`,
228+
storage_url: "file:///test/minimal.txt",
229+
mime_type: "text/plain",
230+
file_size: 0,
231+
});
232+
expect(sourceError).toBeNull();
233+
234+
tracker.trackSource(sourceId);
235+
tracker.trackEntity(deletedEntityId);
236+
tracker.trackEntity(activeEntityId);
237+
238+
const { error: entityError } = await db.from("entities").insert([
239+
{
240+
id: deletedEntityId,
241+
entity_type: "task",
242+
canonical_name: "Deleted task for pagination filter test",
243+
user_id: localUserId,
244+
},
245+
{
246+
id: activeEntityId,
247+
entity_type: "task",
248+
canonical_name: "Active task for pagination filter test",
249+
user_id: localUserId,
250+
},
251+
]);
252+
expect(entityError).toBeNull();
253+
254+
const { error: observationError } = await db.from("observations").insert([
255+
{
256+
entity_id: deletedEntityId,
257+
entity_type: "task",
258+
source_id: sourceId,
259+
fields: { title: "Deleted task" },
260+
user_id: localUserId,
261+
source_priority: 100,
262+
schema_version: "1.0",
263+
observed_at: new Date(Date.now() - 2000).toISOString(),
264+
},
265+
{
266+
entity_id: activeEntityId,
267+
entity_type: "task",
268+
source_id: sourceId,
269+
fields: { title: "Active task" },
270+
user_id: localUserId,
271+
source_priority: 100,
272+
schema_version: "1.0",
273+
observed_at: new Date(Date.now() - 2000).toISOString(),
274+
},
275+
{
276+
entity_id: deletedEntityId,
277+
entity_type: "task",
278+
source_id: sourceId,
279+
fields: { _deleted: true },
280+
user_id: localUserId,
281+
source_priority: 1000,
282+
schema_version: "1.0",
283+
observed_at: new Date(Date.now() - 1000).toISOString(),
284+
},
285+
]);
286+
expect(observationError).toBeNull();
287+
288+
const entities = await queryEntities({
289+
userId: localUserId,
290+
entityType: "task",
291+
includeMerged: false,
292+
limit: 1,
293+
offset: 0,
294+
});
295+
296+
expect(entities).toHaveLength(1);
297+
expect(entities[0].entity_id).toBe(activeEntityId);
298+
299+
const withCount = await queryEntitiesWithCount({
300+
userId: localUserId,
301+
entityType: "task",
302+
includeMerged: false,
303+
limit: 20,
304+
offset: 0,
305+
});
306+
307+
expect(withCount.entities).toHaveLength(1);
308+
expect(withCount.total).toBe(1);
309+
});
215310
});
216311

217312
describe("retrieve_entity_by_identifier variations", () => {

0 commit comments

Comments
 (0)