{"id":18582111,"url":"https://github.com/lorefnon/collection-joiner","last_synced_at":"2026-03-05T14:31:06.535Z","repository":{"id":172351971,"uuid":"649179549","full_name":"lorefnon/collection-joiner","owner":"lorefnon","description":"Type-safe utilities to join multiple collections from different sources into a single hierarchy","archived":false,"fork":false,"pushed_at":"2025-03-16T18:09:44.000Z","size":334,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-02-18T15:57:50.147Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lorefnon.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2023-06-04T03:24:28.000Z","updated_at":"2025-03-16T18:09:47.000Z","dependencies_parsed_at":"2023-12-03T10:23:09.622Z","dependency_job_id":"6a3f8536-2dbf-4d54-847a-04597fa5390c","html_url":"https://github.com/lorefnon/collection-joiner","commit_stats":null,"previous_names":["lorefnon/collection-joiner"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/lorefnon/collection-joiner","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lorefnon%2Fcollection-joiner","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lorefnon%2Fcollection-joiner/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lorefnon%2Fcollection-joiner/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lorefnon%2Fcollection-joiner/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lorefnon","download_url":"https://codeload.github.com/lorefnon/collection-joiner/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lorefnon%2Fcollection-joiner/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30130293,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-05T12:40:50.676Z","status":"ssl_error","status_checked_at":"2026-03-05T12:39:32.209Z","response_time":93,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-07T00:09:15.374Z","updated_at":"2026-03-05T14:31:06.452Z","avatar_url":"https://github.com/lorefnon.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# collection-joiner\n\nWhen developing APIs or writing integration solutions, we often fetch data from multiple sources and combine them together. \nThis requires quite a bit of boilerplate even if you use utility libraries like lodash.\n\nThis library aims to be provide a simple type-safe utility that makes the task of combining multiple collections simpler using an intuitive association API.\n\nYou may find this API to be reminiscent of association APIs found in ORMs. However, collection-joiner is completely agnostic about \nhow these collections are obtained - so you could for example, fetch a list of users from a database, \na list of departments from another service, a list of roles from a key value store and merge them into a single hierarchy when constructing a response.\n\n## Usage/Examples\n\nLet's say we have following collections:\n\n```ts\nconst users = [{\n    id: 1,\n    name: \"Wei Shi Lindon\",\n    elderSiblingId: 3\n}, {\n    id: 2,\n    name: \"Yerin\"\n}, {\n    id: 3,\n    name: \"Wei Shi Kelsa\"\n}];\n\nconst goldSigns = [{\n    userId: 1,\n    path: \"Path of black flame\",\n    description: \"Black eyes with blood-red irises\"\n}, {\n    userId: 1,\n    path: \"Path of twin stars\",\n    description: \"Blue eyes with white irises\"\n}, {\n    userId: 2,\n    path: \"Path of the endless sword\",\n    description: \"Six red metalic limbs\"\n}];\n\nconst ranks = [{\n    userId: 1,\n    rank: \"Arch Lord\"\n}, {\n    userId: 2,\n    rank: \"Herald\"\n}, {\n    userId: 3,\n    rank: \"Low Gold\"\n}];\n```\n\nTo create a merged collection, where each user has been associated with their rank (1:1 relation), elder sibling (1:0/1 relation) and goldsigns (1:N relation), we can do:\n\n```ts\nimport { extend } from \"@lorefnon/collection-joiner\";\n\nextend(users, ({ link, own }) =\u003e ({\n    // Populate rank by associating id of user to userId of ranks\n    rank: link(own.id).toOneOf(ranks, rank =\u003e rank.userId),\n    // Populate elderSibling by associating elderSiblingId of user to id of user\n    elderSibling: link(own.elderSiblingId).toOneOrNoneOf(users, user =\u003e user.id),\n    // Populate goldSigns by associating id of user to userId of goldSigns\n    goldSigns: link(own.id).toManyOf(goldSigns, gs =\u003e gs.userId)\n}))\n```\n\nThis will return following structure:\n\n```ts\n    [\n      {\n        // user:\n        id: 1,\n        name: 'Wei Shi Lindon',\n        rank: {\n          value: {\n            rank: 'Arch Lord',\n            userId: 1,\n          },\n        },\n        elderSiblingId: 3,\n\n        // Associated collections:\n\n        // 1:1 Association:\n        elderSibling: {\n          value: {\n            id: 3,\n            name: 'Wei Shi Kelsa',\n          },\n        },\n\n        // 1:N Association:\n        goldSigns: {\n          values: [\n            {\n              description: 'Black eyes with blood-red irises',\n              path: 'Path of black flame',\n              userId: 1,\n            },\n            {\n              description: 'Blue eyes with white irises',\n              path: 'Path of twin stars',\n              userId: 1,\n            },\n          ],\n        },\n      },\n      {\n        id: 2,\n        name: 'Yerin',\n        rank: {\n          value: {\n            rank: 'Herald',\n            userId: 2,\n          },\n        },\n        elderSibling: {\n          value: undefined,\n        },\n        goldSigns: {\n          values: [\n            {\n              description: 'Six red metalic limbs',\n              path: 'Path of the endless sword',\n              userId: 2,\n            },\n          ],\n        },\n      },\n      {\n        id: 3,\n        name: 'Wei Shi Kelsa',\n        rank: {\n          value: {\n            rank: 'Low Gold',\n            userId: 3,\n          },\n        },\n        elderSibling: {\n          value: undefined,\n        },\n        goldSigns: {\n          values: [],\n        },\n      },\n    ]\n```\n\nBy default associated references are wrapped in `{ value: associatedOne }` and `{ values: associatedMany }` wrappers, to make it explicit to consumers whether certain association is missing or was not fetched.\n\nYou can avoid this wrapper by using `.unwrap()`:\n\n```ts\nimport { extend } from \"@lorefnon/collection-joiner\";\n\nextend(users, ({ link, own }) =\u003e ({\n      rank: link(own.id).toOneOf(ranks, r =\u003e r.userId).unwrap(),\n      elderSibling: link(own.elderSiblingId).toOneOrNoneOf(users, u =\u003e u.id).unwrap(),\n      goldSigns: link(own.id).toManyOf(goldSigns, gs =\u003e gs.userId).unwrap()\n}))\n```\n\nWhich returns:\n\n```ts\n   [\n      {\n        elderSibling: {\n          id: 3,\n          name: 'Wei Shi Kelsa',\n        },\n        elderSiblingId: 3,\n        goldSigns: [\n          {\n            description: 'Black eyes with blood-red irises',\n            path: 'Path of black flame',\n            userId: 1,\n          },\n          {\n            description: 'Blue eyes with white irises',\n            path: 'Path of twin stars',\n            userId: 1,\n          },\n        ],\n        id: 1,\n        name: 'Wei Shi Lindon',\n        rank: {\n          rank: 'Arch Lord',\n          userId: 1,\n        },\n      },\n      {\n        elderSibling: undefined,\n        goldSigns: [\n          {\n            description: 'Six red metalic limbs',\n            path: 'Path of the endless sword',\n            userId: 2,\n          },\n        ],\n        id: 2,\n        name: 'Yerin',\n        rank: {\n          rank: 'Herald',\n          userId: 2,\n        },\n      },\n      {\n        elderSibling: undefined,\n        goldSigns: [],\n        id: 3,\n        name: 'Wei Shi Kelsa',\n        rank: {\n          rank: 'Low Gold',\n          userId: 3,\n        },\n      },\n    ]\n```\n\nWe don't really recommend using this, until you need to conform to a type that you don't control.\n\n### Extend mutating original collection:\n\nBy default, extend will leave the collection provided as input as is, and return a new collection. However, you can pass `mutate: true` option to update the collection in place. This may be useful if you are dealing with reactive collections (eg. vue) or building object graphs through multiple invocations of `extend`\n\n```ts\nconst extendedUsers = extend(users, ({ link, own }) =\u003e ({\n    // Populate rank by associating id of user to userId of ranks\n    rank: link(own.id).toOneOf(ranks, r =\u003e r.userId),\n    // Populate elderSibling by associating elderSiblingId of user to userId of ranks\n    elderSibling: link(own.elderSiblingId).toOneOrNoneOf(users, u =\u003e u.id),\n    // Populate goldSigns by associating id of user to userId of goldSigns\n    goldSigns: link(own.id).toManyOf(goldSigns, gs =\u003e gs.userId)\n}), { mutate: true })\n\nextendedUsers === users // true\n```\n\n### Fetching collections\n\nWhile data fetching is not the primary goal of this utility, for convenience we support a `extendAsync` utility as well which can accept async functions or thunks which resolve to collections.\n\n```ts\nimport { extendAsync } from \"@lorefnon/collection-joiner\";\n\nconst fetchRanks = async () =\u003e {\n    // Fetch ranks from database...\n    return [{ userId: 1, rank: \"Arch Lord\" }];\n};\n\nawait extendAsync(users, ({ link, own }) =\u003e ({\n    // Populate rank by associating id of user to userId of ranks\n    rank: link(own.id)\n      // Async function that resolves to collection is acceptable\n      .toOneOf(fetchRanks, rank =\u003e rank.userId)\n      .if(() =\u003e /* If client is asking for rank */ true),\n\n    // Populate elderSibling by associating elderSiblingId of user to id of user\n    elderSibling: link(own.elderSiblingId)\n      // If the operation has already started, promise is also acceptable\n      .toOneOrNoneOf(Promise.resolve(users), user =\u003e user.id),\n}))\n```\n\nIf passing thunks defined inline, we need to exercise caution if multiple relations are using the same data source.\n\nFor example: \n\n```ts\nconst extUsers = await extendAsync(users, async ({ own, link }) =\u003e ({\n  elderSibling: link(own.elderSiblingId)\n      .toOneOrNoneOf(\n        async () =\u003e (await fetch(\"/family/1/users\")).json(), \n        it =\u003e it.id\n      )\n      .if(() =\u003e linkSiblings),\n  parents: link(own.parentIds)\n      .toManyOf(\n        async () =\u003e (await fetch(\"/family/1/users\")).json(), \n        it =\u003e it.id\n      ),\n}));\n```\n\nThe users will be fetched twice, because even though we are making the same request for multiple associations, the library\nthe does not have a good way to infer that.\n\nInstead we can extract out the fetcher and reuse that: \n\n```ts\nconst fetchUsers = async () =\u003e (await fetch(\"/family/1/users\")).json();\n\nconst extUsers = await extendAsync(users, async ({ own, link }) =\u003e ({\n  elderSibling: link(own.elderSiblingId)\n      .toOneOrNoneOf(\n        fetchUsers, \n        it =\u003e it.id\n      )\n      .if(() =\u003e linkSiblings),\n  parents: link(own.parentIds)\n      .toManyOf(\n        fetchUsers,\n        it =\u003e it.id\n      ),\n}));\n```\n\nNow, users will be fetched only once because the library is smart enough to detect that identity of the fetcher functions is same.\n\nNote that this duplication support is not a substitute for caching. This library does not keep track of results, separate extendAsync calls will perform separate requests.\n\nA lower level `fetchAll` utility is also available, when it is desirable to have fetching and merging as separate steps. This can be\nuseful for example if in same request multiple collections are available, which we then want to merge together.\n\n```ts\nconst extUsers = await extendAsync(users, async ({ own, link }) =\u003e {\n    // Fetch users and ranks in parallel\n    const rels = await fetchAll({\n        ranks: {\n            // fetch ranks \n            fetch: async () =\u003e ranks,\n            // A guard can be passed to indicate if the collection needs to be fetched\n            //\n            // This is convenient for example, if based on user provided parameters we\n            // need to decide if ranks are needed or not\n            if: () =\u003e true\n        },\n\n        // When guards are not needed, it is sufficient to just pass\n        // a fetcher function\n        users: async () =\u003e users,\n    })\n\n    // Results are available in an object which mirrors the shape of request\n    // rels: { ranks?: Rank[], users: User[] }\n\n    // Now we can use the fetched collections for merging\n    return {\n        rank: link(own.id)\n            .toOneOf(rels.ranks ?? [], it =\u003e it.userId)\n            .if(() =\u003e linkRanks),\n        elderSibling: link(own.elderSiblingId)\n            .toOneOrNoneOf(rels.users, it =\u003e it.id)\n            .if(() =\u003e linkSiblings),\n        goldSigns: link(own.id)\n            .toManyOf(async () =\u003e goldSigns, it =\u003e it.userId),\n        loveInterests: link(own.loveInterestIds)\n            .toManyOf(rels.users, it =\u003e it.id),\n        parents: link(own.parentIds)\n            .toManyOf(rels.users, it =\u003e it.id),\n    }\n});\n```\n\nOf course, `fetchAll` is a generic utility. We can use it independent of `extend`/`extendAsync` too.\n\n# License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Florefnon%2Fcollection-joiner","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Florefnon%2Fcollection-joiner","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Florefnon%2Fcollection-joiner/lists"}