Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[V2] Service to load data from different sources #641

Draft
wants to merge 5 commits into
base: v2
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
"build:www": "pnpm --filter www build"
},
"packageManager": "[email protected]"
}
}
3 changes: 3 additions & 0 deletions packages/service/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/coverage/
/.tsimp/
/node_modules/
47 changes: 47 additions & 0 deletions packages/service/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"noUnreachable": "warn"
},
"style": {
"useConsistentArrayType": {
"level": "error",
"options": {
"syntax": "generic"
}
}
}
}
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
},
"files": {
"ignore": ["./dist", "./node_modules"]
},
"formatter": {
"lineWidth": 120
},
"overrides": [
{
"include": ["*.svelte"],
"linter": {
"rules": {
"style": {
"useConst": "off"
}
}
}
}
]
}
29 changes: 29 additions & 0 deletions packages/service/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "sveltesociety.dev-service",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"lint": "run-p -l -c --aggregate-output lint:**",
"lint:biome": "biome check --colors=force .",
"lint:package.json": "pjv --warnings --recommendations --spec=npm",
"lint:jscpd": "jscpd --mode strict --exitCode 1 --reporters consoleFull lib/*.ts",
"lint:typescript": "tsc --noEmit",
"fix": "biome check --write ."
},
"keywords": [],
"author": "",
"license": "MIT",
"type": "module",
"devDependencies": {
"@biomejs/biome": "^1.8.2",
"jscpd": "^4.0.4",
"npm-run-all": "^4.1.5",
"package-json-validator": "^0.6.4",
"tslib": "^2.6.3",
"typescript": "^5.5.2"
},
"dependencies": {
"lru-cache": "^10.3.0"
}
}
53 changes: 53 additions & 0 deletions packages/service/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { LRUCache } from "lru-cache";

export interface CacheService {
save(ref: string, data: string): Promise<string>;
get(ref: string): Promise<string | undefined>;
has(ref: string): Promise<boolean>;
}

export class MemoryCache implements CacheService {
private readonly cache = new LRUCache<string, string>({
ttl: 3600 * 1000, // Keep in memory for 1h
ttlAutopurge: true,
max: 1000, // A maximum on 1000 item are keep in the cache
});
save(ref: string, data: string): Promise<string> {
this.cache.set(ref, data);
return Promise.resolve(data);
}
get(ref: string): Promise<string | undefined> {
return Promise.resolve(this.cache.get(ref));
}
has(ref: string): Promise<boolean> {
return Promise.resolve(this.cache.has(ref));
}
}

export const shortTermCache: CacheService = new MemoryCache();

// TODO: Replace with DB or Redis or Memcached
export const longTermCache: CacheService = new MemoryCache();

export function Memorize(cacheService: CacheService): MethodDecorator {
return (target, propertyKey, descriptor: PropertyDescriptor) => {
// biome-ignore lint/complexity/noBannedTypes: We don't know the type at compilation time
const original = descriptor.value as Function;
// biome-ignore lint/suspicious/noExplicitAny: We don't know the type at compilation time
descriptor.value = function (this: ThisType<any>, ...args: Array<any>) {
const key = `${target.constructor.name}#${String(propertyKey)}#${JSON.stringify(args)}`;
return cacheService
.has(key)
.then((inCache) => {
if (inCache) {
return cacheService.get(key);
}
return Promise.resolve(original.bind(this)(...args)).then((result) =>
cacheService.save(key, JSON.stringify(result)),
);
})
.then((saved) => JSON.parse(saved as string));
};
return descriptor;
};
}
39 changes: 39 additions & 0 deletions packages/service/src/collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ComposedService, type ContentData, type ServiceMetadata } from "./services/abstract.js";
import { ArticleService } from "./services/article-service.js";
import { GuildService } from "./services/guild-service.js";
import { PackageService } from "./services/package-service.js";
import { type ConnectedService, SocietyStatsService } from "./services/society-stats-service.js";
import { VideoService } from "./services/video-service.js";
import {GuildEventService} from "./services/guild-event-service.js";

// biome-ignore lint/suspicious/noExplicitAny:
export class CollectorService extends ComposedService<any> {
private readonly stats = new SocietyStatsService();
constructor(private userService: ConnectedService) {
super([new VideoService(), new PackageService(), new ArticleService(), new GuildService(), new GuildEventService()]);
}

async getInformation(metadata: ServiceMetadata): Promise<ContentData & StatsData & Record<string, unknown>> {
const stats = this.stats.getInformation(metadata);
const connected = this.userService.getInformation(metadata);
const base = super.getInformation(metadata);
return Promise.all([stats, connected, base]).then(([stats, connected, base]) => ({
...base,
...stats,
...connected,
}));
}

async getAllInformation(
metadata: Array<ServiceMetadata>,
): Promise<Array<ContentData & StatsData & Record<string, unknown>>> {
return Promise.all(metadata.map((item) => this.getInformation(item)));
}
}

export type StatsData = {
likes: number;
liked: boolean;
saved: boolean;
connected: boolean;
};
43 changes: 43 additions & 0 deletions packages/service/src/services/abstract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export type ServiceMetadata = {
type: string;
identifier: string;
};

export type ContentData = {
type: string;
name: string;
author: string;
lastUpdate?: string;
keywords: Array<string>;
description: string;
url: string
};

export interface ServiceInterface<ServiceData extends object> {
canHandle(metadata: ServiceMetadata): Promise<boolean>;
getInformation(metadata: ServiceMetadata): Promise<(ContentData & ServiceData) | never>;
}

export class ComposedService<Additional extends object> implements ServiceInterface<Additional> {
constructor(
private services: Array<ServiceInterface<Additional>>,
private throwIfError = true,
) {}

canHandle(metadata: ServiceMetadata): Promise<boolean> {
return Promise.all(this.services.map((service) => service.canHandle(metadata))).then((results) =>
results.some(Boolean),
);
}
async getInformation(metadata: ServiceMetadata): Promise<(ContentData & Additional) | never> {
for (const service of this.services) {
if (await service.canHandle(metadata)) {
return service.getInformation(metadata);
}
}
if (this.throwIfError) {
throw new Error("Incompatible data service");
}
return {} as ContentData & Additional;
}
}
48 changes: 48 additions & 0 deletions packages/service/src/services/article-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Memorize, longTermCache } from "../cache.js";
import { ComposedService, type ContentData, type ServiceInterface, type ServiceMetadata } from "./abstract.js";

export const TYPE = "article";
export const RECIPE_TYPE = "recipe";

export class ArticleService extends ComposedService<{ preview: string }> {
constructor() {
super([new RecipeService()]);
}
}

export class RecipeService implements ServiceInterface<{ preview: string }> {
canHandle(metadata: ServiceMetadata): Promise<boolean> {
return Promise.resolve([RECIPE_TYPE, TYPE].includes(metadata.type));
}
@Memorize(longTermCache)
getInformation(metadata: ServiceMetadata): Promise<(ContentData & { preview: string }) | never> {
return Promise.resolve({
type: RECIPE_TYPE,
name: "Reactivity",
author: "John Doe",
url: '/post/reactivity',
description:
"The reactivity system introduced in Svelte 3 has made it easier than ever to trigger updates to the DOM. Despite this, there are a few simple rules that you must always follow. This guide explains how Svelte’s reactivity system works, what you can and cannot do, as well a few pitfalls to avoid.",
keywords: ["reactivity", "dom"],
lastUpdate: "Monday, June 14, 2021",
preview:
"### Top-level variables\n" +
"The simplest way to make your Svelte components reactive is by using an assignment operator. Any time Svelte sees an assignment to a top-level variable an update is scheduled. A 'top-level variable' is any variable that is defined inside the script element but is not a child of anything, meaning, it is not inside a function or a block. Incidentally, these are also the only variables that you can reference in the DOM. Let's look at some examples.\n" +
"\n" +
"The following works as expected and update the dom:\n" +
"```html\n" +
"<script>\n" +
"\tlet num = 0;\n" +
"\n" +
"\tfunction updateNum() {\n" +
"\t\tnum = 25;\n" +
"\t}\n" +
"</script>\n" +
"\n" +
"<button on:click={updateNum}>Update</button>\n" +
"\n" +
"<p>{num}</p>\n```\n" +
"Svelte can see that there is an assignment to a top-level variable and knows to re-render after the num variable is modified.",
});
}
}
104 changes: 104 additions & 0 deletions packages/service/src/services/guild-event-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {Memorize, shortTermCache} from "../cache.js";
import type { ContentData, ServiceInterface, ServiceMetadata } from "./abstract.js";

export const TYPE = "guild-event" as const;

export class GuildEventService implements ServiceInterface<{ cover: string, startAt: string, venue?: [number, number] }> {
canHandle(metadata: ServiceMetadata): Promise<boolean> {
return Promise.resolve(metadata.type === TYPE);
}
async getInformation(metadata: ServiceMetadata): Promise<(ContentData & { cover: string, startAt: string, venue?: [number, number] }) | never> {
let event = (await this.getGuildEvents('svelte-society')).events.edges.find(event => event.node.id === metadata.identifier)?.node

if (event === undefined) {
event = await this.getOneEvent(metadata.identifier)
}

if (event) {
return {
type: TYPE,
cover: event.generatedSocialCardURL,
keywords: [],
url: `http://guild.host/events/${event.prettyUrl}`,
author: event.owner.name,
venue: event.hasVenue && event.venue?.address.location !== null ? event.venue?.address.location.geojson.coordinates : undefined,
description: event.description,
name: event.name,
startAt: event.startAt
}
}

throw new Error("Event not found");
}

@Memorize(shortTermCache)
private getGuildEvents(id: string): Promise<GuildHostGuildEventsResponse> {
return fetch(`https://guild.host/api/next/${id}/events`).then(response => response.json())
}

@Memorize(shortTermCache)
private getOneEvent(id: string): Promise<GuildHostEventResponse> {
return fetch(`https://guild.host/api/next/node/${id}`).then(response => response.json())
}

async getAllServiceMetadata(): Promise<Array<ServiceMetadata>> {
return this.getGuildEvents('svelte-society').then(response => response.events.edges.map(event => ({
type: TYPE,
identifier: event.node.id
})))
}
}

type GuildHostGuildEventsResponse = {
"__typename": "Guild", "events": {
"edges": Array<{
"node": GuildHostEventResponse,
"cursor": string
}>,
"pageInfo": {
"hasPreviousPage": boolean,
"hasNextPage": boolean,
"startCursor": string,
"endCursor": string
}
}, "__isNode": "Guild", "id": string
};

type GuildHostEventResponse = {
"__typename"?: "Event",
"id": string,
"slug": string,
"prettyUrl": string,
"name": string,
"description": string,
"startAt": string,
"endAt": string,
"timeZone": string,
"visibility": "PUBLIC",
"hasVenue": boolean,
"hasExternalUrl": boolean,
"owner": {
"__typename": "Guild",
"id": string,
"name": string,
"__isNode": "Guild"
},
"uploadedSocialCard": null | {
"url": string,
"id": string
},
"generatedSocialCardURL": string,
"presentations": { "edges": [] },
"venue": null | {
"address": {
"location": null | {
"__typename": "GeometryPoint",
"geojson": { "type": "Point", "coordinates": [number, number] }
},
"id": string
},
"id": string
},
"createdAt": string,
"updatedAt": string
}
Loading