diff --git a/.changeset/activity-contributor-legacy-strings.md b/.changeset/activity-contributor-legacy-strings.md new file mode 100644 index 0000000..64e05f9 --- /dev/null +++ b/.changeset/activity-contributor-legacy-strings.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Activity responses now tolerate legacy contributor URI strings without failing hydration. The original activity record is preserved, while those strings are omitted from the hydrated `contributors` projection. diff --git a/api/lexicons/org.hypercerts.claim.getActivity.json b/api/lexicons/org.hypercerts.claim.getActivity.json index 96f08c2..a47a9b3 100644 --- a/api/lexicons/org.hypercerts.claim.getActivity.json +++ b/api/lexicons/org.hypercerts.claim.getActivity.json @@ -47,7 +47,7 @@ "record": { "type": "ref", "ref": "org.hypercerts.claim.activity" }, "contributors": { "type": "array", - "description": "Hydrated contributor projection in the same order and with the same multiplicity as record.contributors; omitted only when the source field is absent.", + "description": "Hydrated projection of object entries in record.contributors, preserving their relative order and multiplicity; legacy string entries are skipped and the original record remains unchanged. Omitted only when the source field is absent; if it contains no object entries, this is an empty array.", "items": { "type": "ref", "ref": "#activityContributorView" } } } diff --git a/api/lua/endpoints/getActivity.lua b/api/lua/endpoints/getActivity.lua index d06ab44..0841bac 100644 --- a/api/lua/endpoints/getActivity.lua +++ b/api/lua/endpoints/getActivity.lua @@ -129,26 +129,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection - - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/lua/endpoints/listActivities.lua b/api/lua/endpoints/listActivities.lua index 0023c12..c43e79f 100644 --- a/api/lua/endpoints/listActivities.lua +++ b/api/lua/endpoints/listActivities.lua @@ -129,26 +129,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection - - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/lua/endpoints/listCollectionItems.lua b/api/lua/endpoints/listCollectionItems.lua index 747b416..68d559f 100644 --- a/api/lua/endpoints/listCollectionItems.lua +++ b/api/lua/endpoints/listCollectionItems.lua @@ -150,26 +150,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection - - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/lua/endpoints/listEntityFollowing.lua b/api/lua/endpoints/listEntityFollowing.lua index 70f55be..7848983 100644 --- a/api/lua/endpoints/listEntityFollowing.lua +++ b/api/lua/endpoints/listEntityFollowing.lua @@ -192,26 +192,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection - - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/lua/endpoints/searchActivities.lua b/api/lua/endpoints/searchActivities.lua index 58e4fcc..4040250 100644 --- a/api/lua/endpoints/searchActivities.lua +++ b/api/lua/endpoints/searchActivities.lua @@ -129,26 +129,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection - - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/lua/shared/activityProjection.lua b/api/lua/shared/activityProjection.lua index 3f7870b..12ee77e 100644 --- a/api/lua/shared/activityProjection.lua +++ b/api/lua/shared/activityProjection.lua @@ -109,26 +109,28 @@ local function activity_projection_hydrate_views(views) error("ActivityQueryFailed: activity contributors are not an array", 0) end local projections = {} - for contributor_index, source in ipairs(source_contributors) do - local projection = {} - for key, value in pairs(source) do projection[key] = value end - projection.contributorInformation = ACTIVITY_PROJECTION_NULL - projection.actor = ACTIVITY_PROJECTION_NULL - projections[contributor_index] = projection + for _, source in ipairs(source_contributors) do + if type(source) ~= "string" then + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = ACTIVITY_PROJECTION_NULL + projection.actor = ACTIVITY_PROJECTION_NULL + projections[#projections + 1] = projection - local identity = source.contributorIdentity - local resolution = { projection = projection } - if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then - local key = activity_projection_identity_key(identity.uri, identity.cid) - resolution.information_key = key - if not seen_references[key] then - seen_references[key] = true - references[#references + 1] = { uri = identity.uri, cid = identity.cid } + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = activity_projection_identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity end - elseif type(identity) == "table" then - resolution.identifier = identity.identity + contributor_resolutions[#contributor_resolutions + 1] = resolution end - contributor_resolutions[#contributor_resolutions + 1] = resolution end projected_contributors[view_index] = projections end diff --git a/api/tests/unit/tooling/activity.test.js b/api/tests/unit/tooling/activity.test.js index fcdb083..340e987 100644 --- a/api/tests/unit/tooling/activity.test.js +++ b/api/tests/unit/tooling/activity.test.js @@ -231,6 +231,38 @@ test('getActivity serializes a null activity indexed_at as explicit JSON null', assert.equal(body.activity.indexedAt, null); }); +test('getActivity skips legacy string contributors in a dense projection and preserves the source record', () => { + const legacyUris = [ + 'at://did:web:legacy-one.invalid/org.hypercerts.claim.contributorInformation/old', + 'at://did:web:legacy-two.invalid/org.hypercerts.claim.contributorInformation/older', + ]; + const mixedRecord = { + $type: ACTIVITY, + title: 'Legacy contributors', + createdAt: indexedAt, + contributors: [ + legacyUris[0], + { contributorIdentity: { identity: contributorDid }, contributionWeight: 'first object' }, + legacyUris[1], + { contributorIdentity: { identity: contributorDid }, contributionWeight: 'second object' }, + ], + }; + const mixed = runGetActivity({ recordJson: mixedRecord, serialize: true }); + assert.equal(mixed.status, 0, `${mixed.stderr}${mixed.stdout}`); + const mixedActivity = JSON.parse(mixed.stdout).activity; + assert.deepEqual(mixedActivity.record, mixedRecord); + assert.deepEqual(mixedActivity.contributors.map(({ contributionWeight }) => contributionWeight), ['first object', 'second object']); + assert.deepEqual(mixedActivity.contributors.map(({ actor }) => actor.did), [contributorDid, contributorDid]); + assert.deepEqual(mixedActivity.contributors.map(({ actor }) => actor.profile.record.displayName), ['Inline contributor', 'Inline contributor']); + + const allLegacyRecord = { ...mixedRecord, contributors: legacyUris }; + const allLegacy = runGetActivity({ recordJson: allLegacyRecord, serialize: true }); + assert.equal(allLegacy.status, 0, `${allLegacy.stderr}${allLegacy.stdout}`); + const allLegacyActivity = JSON.parse(allLegacy.stdout).activity; + assert.deepEqual(allLegacyActivity.record, allLegacyRecord); + assert.deepEqual(allLegacyActivity.contributors, [], 'a present source array with only legacy entries projects as an empty array'); +}); + test('getActivity serializes exact-version contributor indexed_at null and timestamp strings', () => { const exactInformation = { ...rows[2], cid: oldCid, record: 'exact-contributor-information', @@ -370,6 +402,73 @@ function activityRow({ uri, did = authorDid, record, cid, sortTimestamp }) { }; } +test('listActivities continues cursor pages across mixed and all-legacy contributor records', () => { + const legacyUris = [ + 'at://did:web:legacy-one.invalid/org.hypercerts.claim.contributorInformation/old', + 'at://did:web:legacy-two.invalid/org.hypercerts.claim.contributorInformation/older', + ]; + const first = activityRow({ + uri: `at://${authorDid}/${ACTIVITY}/cursor-first`, + cid: 'bafyreiggggggggggggggggggggggggggggggggggggggggggggggggggg', + sortTimestamp: '2025-01-01T00:00:00.000000Z', + record: { title: 'Cursor first', createdAt: '2025-01-01T00:00:00Z' }, + }); + const mixed = activityRow({ + uri: `at://${authorDid}/${ACTIVITY}/cursor-mixed`, + cid: 'bafyreihhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh', + sortTimestamp: '2025-01-02T00:00:00.000000Z', + record: { + title: 'Cursor mixed', createdAt: '2025-01-02T00:00:00Z', + contributors: [ + legacyUris[0], + { contributorIdentity: { identity: contributorDid }, contributionWeight: 'kept object' }, + legacyUris[1], + ], + }, + }); + const allLegacy = activityRow({ + uri: `at://${authorDid}/${ACTIVITY}/cursor-legacy`, + cid: 'bafyreijjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj', + sortTimestamp: '2025-01-03T00:00:00.000000Z', + record: { title: 'Cursor legacy', createdAt: '2025-01-03T00:00:00Z', contributors: legacyUris }, + }); + const profile = (did, displayName) => ({ + uri: `at://${did}/${PROFILE}/self`, did, collection: PROFILE, + cid: 'bafyreikkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk', + indexed_at: indexedAt, record: `profile-${did}`, + record_json: { $type: PROFILE, displayName, createdAt: indexedAt }, + }); + const result = runLuaEndpoint({ + endpoint: 'listActivities', params: { limit: '1' }, + queryResults: [ + [first, mixed], [profile(authorDid, 'Page author')], [], + [mixed, allLegacy], [profile(authorDid, 'Page author'), profile(contributorDid, 'Inline contributor')], [], + [allLegacy], [profile(authorDid, 'Page author')], [], + ], + assertions: ` +assert(#result.activities == 1 and result.activities[1].uri == '${first.uri}') +assert(result.cursor ~= nil, 'the first page must retain its next-page cursor') +params.cursor = result.cursor +local secondPage = handle() +assert(#secondPage.activities == 1 and secondPage.activities[1].uri == '${mixed.uri}') +assert(secondPage.cursor ~= nil, 'a mixed legacy page must retain its next-page cursor') +local mixedActivity = secondPage.activities[1] +assert(#mixedActivity.record.contributors == 3) +assert(mixedActivity.record.contributors[1] == '${legacyUris[0]}' and mixedActivity.record.contributors[3] == '${legacyUris[1]}') +assert(#mixedActivity.contributors == 1 and mixedActivity.contributors[1].contributionWeight == 'kept object') +assert(mixedActivity.contributors[1].actor.did == '${contributorDid}') +assert(mixedActivity.contributors[1].actor.profile.record.displayName == 'Inline contributor') +params.cursor = secondPage.cursor +local thirdPage = handle() +assert(#thirdPage.activities == 1 and thirdPage.activities[1].uri == '${allLegacy.uri}') +assert(#thirdPage.activities[1].contributors == 0, 'all-legacy contributors must project densely as empty') +assert(thirdPage.activities[1].record.contributors[1] == '${legacyUris[0]}') +assert(thirdPage.cursor == nil and #calls == 9) +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + test('listActivities hydrates actor profiles across multiple bounded lookup batches', () => { const contributorDids = Array.from({ length: 501 }, (_, index) => `did:web:contributor-${index}.example`); const uri = `at://${authorDid}/${ACTIVITY}/many-contributors`; @@ -518,6 +617,38 @@ assert(sql:find(' AND ', 1, true), 'search and authors must combine with AND') assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); +test('searchActivities skips legacy contributor strings and keeps valid object projections', () => { + const legacyUri = 'at://did:web:legacy-search.invalid/org.hypercerts.claim.contributorInformation/old'; + const row = activityRow({ + uri: `at://${authorDid}/${ACTIVITY}/search-legacy`, + cid: 'bafyreimmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm', + record: { + title: 'Legacy contributor search', createdAt: indexedAt, + contributors: [legacyUri, { contributorIdentity: { identity: contributorDid }, contributionWeight: 'retained' }], + }, + }); + const authorProfile = { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreinnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn', + indexed_at: indexedAt, record: 'search-author-profile', record_json: { $type: PROFILE, displayName: 'Search author', createdAt: indexedAt }, + }; + const contributorProfile = { + uri: `at://${contributorDid}/${PROFILE}/self`, did: contributorDid, collection: PROFILE, cid: 'bafyreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + indexed_at: indexedAt, record: 'search-contributor-profile', record_json: { $type: PROFILE, displayName: 'Search contributor', createdAt: indexedAt }, + }; + const result = runLuaEndpoint({ + endpoint: 'searchActivities', params: { search: 'legacy contributor' }, + queryResults: [[row], [authorProfile, contributorProfile], []], + assertions: ` +local activity = result.activities[1] +assert(#result.activities == 1 and activity.uri == '${row.uri}') +assert(activity.record.contributors[1] == '${legacyUri}', 'the raw record must retain the legacy URI string') +assert(#activity.contributors == 1 and activity.contributors[1].contributionWeight == 'retained') +assert(activity.contributors[1].actor.profile.record.displayName == 'Search contributor') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + test('listActivities and searchActivities filter by self organization-record presence', () => { for (const { endpoint, params, predicate } of [ { endpoint: 'listActivities', params: { hasOrganizationRecord: 'true' }, predicate: 'EXISTS' }, diff --git a/api/tests/unit/tooling/collection.test.js b/api/tests/unit/tooling/collection.test.js index addc194..96692a4 100644 --- a/api/tests/unit/tooling/collection.test.js +++ b/api/tests/unit/tooling/collection.test.js @@ -635,8 +635,9 @@ assert(second_page.items[1].record == NULL and second_page.cursor == nil) test('listCollectionItems preserves source order and weights and resolves only exact supported versions', () => { const itemCollectionUri = `at://${did}/${COLLECTION}/parent`; - const activityUri = `at://${did}/org.hypercerts.claim.activity/activity-one`; const featureDid = 'did:plc:eeeeeeeeeeeeeeeeeeeeeeee'; + const activityUri = `at://${featureDid}/org.hypercerts.claim.activity/activity-one`; + const legacyContributorUri = `at://${did}/org.hypercerts.claim.contributorInformation/legacy`; const featureUri = `at://${featureDid}/org.hypercerts.entity.feature/feature-one`; const nestedUri = `at://${did}/${COLLECTION}/nested`; const activityCid = 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -658,6 +659,10 @@ test('listCollectionItems preserves source order and weights and resolves only e 'parent-record': parentRecord, 'activity-record': { $type: 'org.hypercerts.claim.activity', title: 'Resolved activity', createdAt: '2025-01-01T00:00:00Z', + contributors: [ + legacyContributorUri, + { contributorIdentity: { identity: featureDid }, contributionWeight: 'retained' }, + ], }, 'feature-record': { $type: 'org.hypercerts.entity.feature', type: 'zone', title: 'Resolved feature', createdAt: '2025-01-01T00:00:00Z', @@ -675,7 +680,7 @@ test('listCollectionItems preserves source order and weights and resolves only e indexed_at: '2025-01-02T03:04:05.000Z', record: 'parent-record', }, { - uri: activityUri, did, collection: 'org.hypercerts.claim.activity', cid: activityCid, + uri: activityUri, did: featureDid, collection: 'org.hypercerts.claim.activity', cid: activityCid, indexed_at: '2025-01-02T03:04:05.000Z', record: 'activity-record', }, { @@ -741,6 +746,10 @@ assert(result.items[1].itemIdentifier.uri == ${JSON.stringify(activityUri)} and assert(result.items[1].record.uri == ${JSON.stringify(activityUri)}) assert(result.items[1].record['$type'] == 'org.hypercerts.claim.getActivity#activityView') assert(result.items[1].record.record.title == 'Resolved activity') +assert(result.items[1].record.record.contributors[1] == '${legacyContributorUri}', 'nested activity source record must retain the legacy URI') +assert(#result.items[1].record.contributors == 1 and result.items[1].record.contributors[1].contributionWeight == 'retained') +assert(result.items[1].record.contributors[1].actor.did == '${featureDid}') +assert(result.items[1].record.contributors[1].actor.profile.record.displayName == 'Feature author') assert(result.items[2].record['$type'] == 'org.hypercerts.collection.listCollectionItems#collectionSummaryView') assert(result.items[2].record.title == 'Nested summary' and result.items[2].record.shortDescription == 'One level only') assert(result.items[3].itemWeight == '1.25') diff --git a/api/tests/unit/tooling/entity-follow.test.js b/api/tests/unit/tooling/entity-follow.test.js index 1cb9aeb..218e98f 100644 --- a/api/tests/unit/tooling/entity-follow.test.js +++ b/api/tests/unit/tooling/entity-follow.test.js @@ -494,6 +494,7 @@ test('listEntityFollowing returns hydrated ActivityView and CollectionView union const activityCollection = 'org.hypercerts.claim.activity'; const collectionCollection = 'org.hypercerts.collection'; const activityUri = `at://${activityDid}/${activityCollection}/activity-one`; + const legacyContributorUri = `at://${activityDid}/org.hypercerts.claim.contributorInformation/legacy`; const collectionUri = `at://${collectionDid}/${collectionCollection}/collection-one`; const rawCollection = { $type: collectionCollection, title: 'Root collection', createdAt: '2025-01-01T00:00:00Z', @@ -503,7 +504,10 @@ test('listEntityFollowing returns hydrated ActivityView and CollectionView union { uri: `at://${actor}/${entityFollow}/activity-follow`, did: actor, cid: 'bafy-fa', indexed_at: null, record: 'follow-activity', sort_timestamp: '2025-01-03T00:00:00.000000Z', follow_record: { $type: entityFollow, subject: { uri: activityUri }, createdAt: '2025-01-03T00:00:00Z' } }, { uri: `at://${actor}/${entityFollow}/collection-follow`, did: actor, cid: 'bafy-fc', indexed_at: null, record: 'follow-collection', sort_timestamp: '2025-01-02T00:00:00.000000Z', follow_record: { $type: entityFollow, subject: { uri: collectionUri }, createdAt: '2025-01-02T00:00:00Z' } }, ]; - const activity = { uri: activityUri, did: activityDid, cid: 'bafy-activity-latest', indexed_at: null, record: 'activity-record', target_record: { $type: activityCollection, title: 'Resolved activity', createdAt: '2025-01-01T00:00:00Z' } }; + const activity = { uri: activityUri, did: activityDid, cid: 'bafy-activity-latest', indexed_at: null, record: 'activity-record', target_record: { + $type: activityCollection, title: 'Resolved activity', createdAt: '2025-01-01T00:00:00Z', + contributors: [legacyContributorUri, { contributorIdentity: { identity: activityDid }, contributionWeight: 'retained' }], + } }; const collection = { uri: collectionUri, did: collectionDid, cid: 'bafy-collection-latest', indexed_at: null, record: 'collection-record', target_record: rawCollection }; const records = Object.fromEntries([ ...follows.map(({ record, follow_record }) => [record, follow_record]), @@ -544,6 +548,9 @@ assert(activity_item.entity['$type'] == 'org.hypercerts.claim.getActivity#activi assert(activity_item.uri == FIXTURE.activityUri and activity_item.entity.uri == FIXTURE.activityUri) assert(activity_item.entity.cid == 'bafy-activity-latest' and activity_item.entity.indexedAt == NULL_VALUE) assert(activity_item.entity.record.title == 'Resolved activity' and activity_item.entity.author.did == FIXTURE.activity.did) +assert(activity_item.entity.record.contributors[1] == '${legacyContributorUri}', 'followed activity source record must retain the legacy URI') +assert(#activity_item.entity.contributors == 1 and activity_item.entity.contributors[1].contributionWeight == 'retained') +assert(activity_item.entity.contributors[1].actor.did == FIXTURE.activity.did) assert(activity_item.follow.uri == FIXTURE.follows[1].uri) assert(collection_item['$type'] == 'app.certified.graph.listEntityFollowing#entityFollowingItem') assert(collection_item.entity['$type'] == 'org.hypercerts.collection.getCollection#collectionView') diff --git a/docs/openapi.json b/docs/openapi.json index 54d2e4c..edbb074 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5770,7 +5770,7 @@ }, "contributors": { "type": "array", - "description": "Hydrated contributor projection in the same order and with the same multiplicity as record.contributors; omitted only when the source field is absent.", + "description": "Hydrated projection of object entries in record.contributors, preserving their relative order and multiplicity; legacy string entries are skipped and the original record remains unchanged. Omitted only when the source field is absent; if it contains no object entries, this is an empty array.", "items": { "$ref": "#/components/schemas/org.hypercerts.claim.getActivity.activityContributorView" } diff --git a/docs/sources/lexicons/org.hypercerts.claim.getActivity.json b/docs/sources/lexicons/org.hypercerts.claim.getActivity.json index d4a6659..59e77b3 100644 --- a/docs/sources/lexicons/org.hypercerts.claim.getActivity.json +++ b/docs/sources/lexicons/org.hypercerts.claim.getActivity.json @@ -93,7 +93,7 @@ }, "contributors": { "type": "array", - "description": "Hydrated contributor projection in the same order and with the same multiplicity as record.contributors; omitted only when the source field is absent.", + "description": "Hydrated projection of object entries in record.contributors, preserving their relative order and multiplicity; legacy string entries are skipped and the original record remains unchanged. Omitted only when the source field is absent; if it contains no object entries, this is an empty array.", "items": { "type": "ref", "ref": "#activityContributorView"