···1-import { create, StateCreator as ZustandStateCreator } from "zustand";
2-import { persist, createJSONStorage } from "zustand/middleware";
3import {
4 AuthenticationSlice,
5 createAuthenticationSlice,
6-} from "./authenticationSlice";
7-import AsyncStorage from "@react-native-async-storage/async-storage";
8-import { createTempSlice, TempSlice } from "./tempSlice";
9-1011/// Put all your non-shared slices here
12-export type Slices = AuthenticationSlice & TempSlice;
13/// Put all your shared slices here
14export type PlusSharedSlices = Slices;
15/// Convenience type for creating a store. Uses the slices type defined above.
16-/// Type parameter T is the type of the state object.
17export type StateCreator<T> = ZustandStateCreator<Slices, [], [], T>;
1819export const useStore = create<PlusSharedSlices>()(
···21 (...a) => ({
22 ...createAuthenticationSlice(...a),
23 ...createTempSlice(...a),
024 }),
25 {
26 partialize: ({ pdsAgent, isAgentReady, ...state }) => state,
2728 onRehydrateStorage: () => (state) => {
29- state?.restorePdsAgent()
30 },
31- name: "mainStore",
32 storage: createJSONStorage(() => AsyncStorage),
33 },
34 ),
···1+import { create, StateCreator as ZustandStateCreator } from 'zustand';
2+import { persist, createJSONStorage } from 'zustand/middleware';
3import {
4 AuthenticationSlice,
5 createAuthenticationSlice,
6+} from './authenticationSlice';
7+import AsyncStorage from '@react-native-async-storage/async-storage';
8+import { createTempSlice, TempSlice } from './tempSlice';
9+import { createPreferenceSlice, PreferenceSlice } from './preferenceSlice';
1011/// Put all your non-shared slices here
12+export type Slices = AuthenticationSlice & TempSlice & PreferenceSlice;
13/// Put all your shared slices here
14export type PlusSharedSlices = Slices;
15/// Convenience type for creating a store. Uses the slices type defined above.
16+/// Type parameter T is the type of the state object.
17export type StateCreator<T> = ZustandStateCreator<Slices, [], [], T>;
1819export const useStore = create<PlusSharedSlices>()(
···21 (...a) => ({
22 ...createAuthenticationSlice(...a),
23 ...createTempSlice(...a),
24+ ...createPreferenceSlice(...a),
25 }),
26 {
27 partialize: ({ pdsAgent, isAgentReady, ...state }) => state,
2829 onRehydrateStorage: () => (state) => {
30+ state?.restorePdsAgent();
31 },
32+ name: 'mainStore',
33 storage: createJSONStorage(() => AsyncStorage),
34 },
35 ),
···1+import { TealContext } from '@/ctx';
2+import { db, profiles } from '@teal/db';
3+import { like, or, sql, lt, gt, and } from 'drizzle-orm';
4+import { OutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors';
5+6+export default async function searchActors(c: TealContext) {
7+ const params = c.req.query();
8+ const limit = (params.limit ? parseInt(params.limit) : 25) || 25; // Ensure limit is a number
9+ const requestedLimit = limit + 1; // Fetch one extra for cursor detection
10+11+ if (!params.q) {
12+ c.status(400);
13+ c.error = new Error('q is required');
14+ return;
15+ }
16+17+ const query = params.q.toLowerCase();
18+19+ try {
20+ let queryBuilder = db
21+ .select({
22+ did: profiles.did,
23+ handle: profiles.handle,
24+ displayName: profiles.displayName,
25+ description: profiles.description,
26+ avatar: profiles.avatar,
27+ banner: profiles.banner,
28+ createdAt: profiles.createdAt,
29+ })
30+ .from(profiles);
31+32+ // Base WHERE clause (always applied)
33+ const baseWhere = or(
34+ like(sql`lower(${profiles.handle})`, `%${query}%`),
35+ like(sql`lower(${profiles.displayName})`, `%${query}%`),
36+ like(sql`lower(${profiles.description})`, `%${query}%`),
37+ sql`${profiles.handle} = ${params.q}`,
38+ sql`${profiles.displayName} = ${params.q}`,
39+ );
40+41+ if (params.cursor) {
42+ // Decode the cursor
43+ const [createdAtStr, didStr] = Buffer.from(params.cursor, 'base64')
44+ .toString('utf-8')
45+ .split(':');
46+47+ const createdAt = new Date(createdAtStr);
48+49+ // Cursor condition: (createdAt > cursor.createdAt) OR (createdAt == cursor.createdAt AND did > cursor.did)
50+ queryBuilder.where(
51+ and(
52+ baseWhere, // Apply the base search terms
53+ or(
54+ gt(profiles.createdAt, createdAt),
55+ and(
56+ sql`${profiles.createdAt} = ${createdAt}`,
57+ gt(profiles.did, didStr), // Compare did as string
58+ ),
59+ ),
60+ ),
61+ );
62+ } else {
63+ queryBuilder.where(baseWhere); // Just the base search if no cursor
64+ }
65+66+ queryBuilder
67+ .orderBy(profiles.createdAt, profiles.did)
68+ .limit(requestedLimit); // Order by both, limit + 1
69+70+ const results = await queryBuilder;
71+72+ // Build the next cursor (if there are more results)
73+ let nextCursor = null;
74+ if (results.length > limit) {
75+ const lastResult = results[limit - 1]; // Get the *limit*-th, not limit+1-th
76+ nextCursor = Buffer.from(
77+ `${lastResult.createdAt?.toISOString() || ''}:${lastResult.did}`,
78+ ).toString('base64');
79+ results.pop(); // Remove the extra record we fetched
80+ }
81+ const res: OutputSchema = {
82+ actors: results.map((profile) => ({
83+ did: profile.did,
84+ handle: profile.handle ?? undefined,
85+ displayName: profile.displayName ?? undefined,
86+ avatar: profile.avatar ?? undefined,
87+ banner: profile.banner ?? undefined,
88+ })),
89+ cursor: nextCursor || undefined,
90+ };
91+92+ return res;
93+ } catch (error) {
94+ console.error('Database error:', error);
95+ c.status(500);
96+ throw new Error('Internal server error');
97+ }
98+}
+102-53
apps/aqua/src/xrpc/feed/getActorFeed.ts
···1-import { TealContext } from "@/ctx";
2-import { db, tealSession, play } from "@teal/db";
3-import { eq, and, lt } from "drizzle-orm";
4-import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed";
56export default async function getActorFeed(c: TealContext) {
7 const params = c.req.query();
8- if (!params.authorDid) {
9- throw new Error("authorDid is required");
10 }
1112- let limit = 20
1314- if(params.limit) {
15- limit = Number(params.limit)
16- if(limit > 50) throw new Error("Limit is over max allowed.")
17 }
1819 // 'and' is here for typing reasons
20- let whereClause = and(eq(play.authorDid, params.authorDid));
2122 // Add cursor pagination if provided
23 if (params.cursor) {
24- const [cursorPlay] = await db
25- .select({ createdAt: play.createdAt })
26- .from(play)
27- .where(eq(play.uri, params.cursor))
28 .limit(1);
290030 if (!cursorPlay) {
31- throw new Error("Cursor not found");
32 }
3334- whereClause = and(whereClause, lt(play.createdAt, cursorPlay.createdAt));
35 }
3637- const plays = await db
38- .select()
39- .from(play)
00000000000000000000000000040 .where(whereClause)
41- .orderBy(play.createdAt)
42- .limit(10);
43-44- if (plays.length === 0) {
45- throw new Error("Play not found");
46- }
0000000000000004748 return {
49- plays: plays.map(
050 ({
51- uri,
52- authorDid,
53- createdAt,
54- indexedAt,
55 trackName,
56- trackMbId,
57- recordingMbId,
58 duration,
59- artistNames,
60- artistMbIds,
61 releaseName,
62- releaseMbId,
63 isrc,
64 originUrl,
65 musicServiceBaseDomain,
66 submissionClientAgent,
67 playedTime,
068 }) => ({
69- uri,
70- authorDid,
71- createdAt,
72- indexedAt,
73- trackName,
74- trackMbId,
75- recordingMbId,
76- duration,
77- artistNames,
78- artistMbIds,
79- releaseName,
80- releaseMbId,
81- isrc,
82- originUrl,
83- musicServiceBaseDomain,
84- submissionClientAgent,
85- playedTime,
00000086 }),
87 ),
088 } as OutputSchema;
89}
···1+import { TealContext } from '@/ctx';
2+import { artists, db, plays, playToArtists } from '@teal/db';
3+import { eq, and, lt, desc, sql } from 'drizzle-orm';
4+import { OutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed';
56export default async function getActorFeed(c: TealContext) {
7 const params = c.req.query();
8+ if (!params.authorDID) {
9+ throw new Error('authorDID is required');
10 }
1112+ let limit = 20;
1314+ if (params.limit) {
15+ limit = Number(params.limit);
16+ if (limit > 50) throw new Error('Limit is over max allowed.');
17 }
1819 // 'and' is here for typing reasons
20+ let whereClause = and(eq(plays.did, params.authorDID));
2122 // Add cursor pagination if provided
23 if (params.cursor) {
24+ const cursorResult = await db
25+ .select()
26+ .from(plays)
27+ .where(eq(plays.uri, params.cursor))
28 .limit(1);
2930+ const cursorPlay = cursorResult[0]?.playedTime;
31+32 if (!cursorPlay) {
33+ throw new Error('Cursor not found');
34 }
3536+ whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any));
37 }
3839+ const playRes = await db
40+ .select({
41+ uri: plays.uri,
42+ did: plays.did,
43+ playedTime: plays.playedTime,
44+ trackName: plays.trackName,
45+ cid: plays.cid,
46+ recordingMbid: plays.recordingMbid,
47+ duration: plays.duration,
48+ releaseName: plays.releaseName,
49+ releaseMbid: plays.releaseMbid,
50+ isrc: plays.isrc,
51+ originUrl: plays.originUrl,
52+ processedTime: plays.processedTime,
53+ submissionClientAgent: plays.submissionClientAgent,
54+ musicServiceBaseDomain: plays.musicServiceBaseDomain,
55+ artists: sql<Array<{ mbid: string; name: string }>>`
56+ COALESCE(
57+ (
58+ SELECT jsonb_agg(jsonb_build_object('mbid', pa.artist_mbid, 'name', pa.artist_name))
59+ FROM ${playToArtists} pa
60+ WHERE pa.play_uri = ${plays.uri}
61+ AND pa.artist_mbid IS NOT NULL
62+ AND pa.artist_name IS NOT NULL -- Ensure both are non-null
63+ ),
64+ '[]'::jsonb -- Correct empty JSONB array literal
65+ )`.as('artists'),
66+ })
67+ .from(plays)
68+ .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`)
69 .where(whereClause)
70+ .groupBy(
71+ plays.uri,
72+ plays.cid,
73+ plays.did,
74+ plays.duration,
75+ plays.isrc,
76+ plays.musicServiceBaseDomain,
77+ plays.originUrl,
78+ plays.playedTime,
79+ plays.processedTime,
80+ plays.rkey,
81+ plays.recordingMbid,
82+ plays.releaseMbid,
83+ plays.releaseName,
84+ plays.submissionClientAgent,
85+ plays.trackName,
86+ )
87+ .orderBy(desc(plays.playedTime))
88+ .limit(limit);
89+ const cursor =
90+ playRes.length === limit ? playRes[playRes.length - 1]?.uri : undefined;
9192 return {
93+ cursor: cursor ?? undefined, // Ensure cursor itself can be undefined
94+ plays: playRes.map(
95 ({
96+ // Destructure fields from the DB result
00097 trackName,
98+ cid: trackMbId, // Note the alias was used here in the DB query select
99+ recordingMbid,
100 duration,
101+ artists, // This is guaranteed to be an array '[]' if no artists, due to COALESCE
0102 releaseName,
103+ releaseMbid,
104 isrc,
105 originUrl,
106 musicServiceBaseDomain,
107 submissionClientAgent,
108 playedTime,
109+ // Other destructured fields like uri, did, etc. are not directly used here by name
110 }) => ({
111+ // Apply '?? undefined' to each potentially nullable/undefined scalar field
112+ trackName: trackName ?? undefined,
113+ trackMbId: trackMbId ?? undefined,
114+ recordingMbId: recordingMbid ?? undefined,
115+ duration: duration ?? undefined,
116+117+ // For arrays derived from a guaranteed array, map is safe.
118+ // The SQL query ensures `artists` is '[]'::jsonb if empty.
119+ // The SQL query also ensures artist.name/mbid are NOT NULL within the jsonb_agg
120+ artistNames: artists.map((artist) => artist.name), // Will be [] if artists is []
121+ artistMbIds: artists.map((artist) => artist.mbid), // Will be [] if artists is []
122+123+ releaseName: releaseName ?? undefined,
124+ releaseMbId: releaseMbid ?? undefined,
125+ isrc: isrc ?? undefined,
126+ originUrl: originUrl ?? undefined,
127+ musicServiceBaseDomain: musicServiceBaseDomain ?? undefined,
128+ submissionClientAgent: submissionClientAgent ?? undefined,
129+130+ // playedTime specific handling: convert to ISO string if exists, else undefined
131+ playedTime: playedTime ? playedTime.toISOString() : undefined,
132+ // Alternative using optional chaining (effectively the same)
133+ // playedTime: playedTime?.toISOString(),
134 }),
135 ),
136+ // Explicitly cast to OutputSchema. Make sure OutputSchema allows undefined for these fields.
137 } as OutputSchema;
138}
+89-34
apps/aqua/src/xrpc/feed/getPlay.ts
···1import { TealContext } from "@/ctx";
2-import { db, tealSession, play } from "@teal/db";
3-import { eq, and } from "drizzle-orm";
4-import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getPlay";
56-export default async function getPlay(c: TealContext) {
7- // do we have required query params?
8 const params = c.req.query();
9- if (params.authorDid === undefined) {
10 throw new Error("authorDid is required");
11 }
12 if (!params.rkey) {
13 throw new Error("rkey is required");
14 }
1516- let res = await db
17- .select()
18- .from(play)
19- .where(
20- and(eq(play.authorDid, params.authorDid), and(eq(play.uri, params.rkey))),
00000000000000000000000000000000021 )
22- .execute();
02324- if (res.length === 0) {
25 throw new Error("Play not found");
26 }
27- res[0];
2829- // return a PlayView
30 return {
31- play: {
32- uri: res[0].uri,
33- authorDid: res[0].authorDid,
34- createdAt: res[0].createdAt,
35- indexedAt: res[0].indexedAt,
36- trackName: res[0].trackName,
37- trackMbId: res[0].trackMbId,
38- recordingMbId: res[0].recordingMbId,
39- duration: res[0].duration,
40- artistNames: res[0].artistNames,
41- artistMbIds: res[0].artistMbIds,
42- releaseName: res[0].releaseName,
43- releaseMbId: res[0].releaseMbId,
44- isrc: res[0].isrc,
45- originUrl: res[0].originUrl,
46- musicServiceBaseDomain: res[0].musicServiceBaseDomain,
47- submissionClientAgent: res[0].submissionClientAgent,
48- playedTime: res[0].playedTime,
49- },
00000000000000000000000050 } as OutputSchema;
51}
···1import { TealContext } from "@/ctx";
2+import { db, plays, playToArtists, artists } from "@teal/db";
3+import { eq, and, lt, desc, sql } from "drizzle-orm";
4+import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed";
56+export default async function getActorFeed(c: TealContext) {
07 const params = c.req.query();
8+ if (!params.authorDid) {
9 throw new Error("authorDid is required");
10 }
11 if (!params.rkey) {
12 throw new Error("rkey is required");
13 }
1415+ // Get plays with artists as arrays
16+ const playRes = await db
17+ .select({
18+ play: plays,
19+ artists: sql<Array<{ mbid: string; name: string }>>`
20+ COALESCE(
21+ array_agg(
22+ CASE WHEN ${artists.mbid} IS NOT NULL THEN
23+ jsonb_build_object(
24+ 'mbid', ${artists.mbid},
25+ 'name', ${artists.name}
26+ )
27+ END
28+ ) FILTER (WHERE ${artists.mbid} IS NOT NULL),
29+ ARRAY[]::jsonb[]
30+ )
31+ `.as("artists"),
32+ })
33+ .from(plays)
34+ .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`)
35+ .leftJoin(artists, sql`${playToArtists.artistMbid} = ${artists.mbid}`)
36+ .where(and(eq(plays.did, params.authorDid), eq(plays.rkey, params.rkey)))
37+ .groupBy(
38+ plays.uri,
39+ plays.cid,
40+ plays.did,
41+ plays.duration,
42+ plays.isrc,
43+ plays.musicServiceBaseDomain,
44+ plays.originUrl,
45+ plays.playedTime,
46+ plays.processedTime,
47+ plays.rkey,
48+ plays.recordingMbid,
49+ plays.releaseMbid,
50+ plays.releaseName,
51+ plays.submissionClientAgent,
52+ plays.trackName,
53 )
54+ .orderBy(desc(plays.playedTime))
55+ .limit(1);
5657+ if (playRes.length === 0) {
58 throw new Error("Play not found");
59 }
060061 return {
62+ plays: playRes.map(({ play, artists }) => {
63+ const {
64+ uri,
65+ did: authorDid,
66+ processedTime: createdAt,
67+ processedTime: indexedAt,
68+ trackName,
69+ cid: trackMbId,
70+ cid: recordingMbId,
71+ duration,
72+ rkey,
73+ releaseName,
74+ cid: releaseMbId,
75+ isrc,
76+ originUrl,
77+ musicServiceBaseDomain,
78+ submissionClientAgent,
79+ playedTime,
80+ } = play;
81+82+ return {
83+ uri,
84+ authorDid,
85+ createdAt: createdAt?.toISOString(),
86+ indexedAt: indexedAt?.toISOString(),
87+ trackName,
88+ trackMbId,
89+ recordingMbId,
90+ duration,
91+ // Replace these with actual artist data from the array
92+ artistNames: artists.map((artist) => artist.name),
93+ artistMbIds: artists.map((artist) => artist.mbid),
94+ // Or, if you want to keep the full artist objects:
95+ // artists: artists,
96+ releaseName,
97+ releaseMbId,
98+ isrc,
99+ originUrl,
100+ musicServiceBaseDomain,
101+ submissionClientAgent,
102+ playedTime: playedTime?.toISOString(),
103+ };
104+ }),
105 } as OutputSchema;
106}
+16-6
apps/aqua/src/xrpc/route.ts
···1-import { EnvWithCtx } from "@/ctx";
2-import { Hono } from "hono";
3-import getPlay from "./feed/getPlay";
4-import getActorFeed from "./feed/getActorFeed";
0056// mount this on /xrpc
7const app = new Hono<EnvWithCtx>();
89-app.get("fm.teal.alpha.getPlay", async (c) => c.json(await getPlay(c)));
10-app.get("fm.teal.alpha.feed.getActorFeed", async (c) =>
11 c.json(await getActorFeed(c)),
0000000012);
1314export const getXrpcRouter = () => {
···1+import { EnvWithCtx } from '@/ctx';
2+import { Hono } from 'hono';
3+import getPlay from './feed/getPlay';
4+import getActorFeed from './feed/getActorFeed';
5+import getProfile from './actor/getProfile';
6+import searchActors from './actor/searchActors';
78// mount this on /xrpc
9const app = new Hono<EnvWithCtx>();
1011+app.get('fm.teal.alpha.feed.getPlay', async (c) => c.json(await getPlay(c)));
12+app.get('fm.teal.alpha.feed.getActorFeed', async (c) =>
13 c.json(await getActorFeed(c)),
14+);
15+16+app.get('fm.teal.alpha.actor.getProfile', async (c) =>
17+ c.json(await getProfile(c)),
18+);
19+20+app.get('fm.teal.alpha.actor.searchActors', async (c) =>
21+ c.json(await searchActors(c)),
22);
2324export const getXrpcRouter = () => {
+46
packages/db/.drizzle/0000_perfect_war_machine.sql
···0000000000000000000000000000000000000000000000
···1+CREATE TABLE "artists" (
2+ "mbid" uuid PRIMARY KEY NOT NULL,
3+ "name" text NOT NULL,
4+ "play_count" integer DEFAULT 0
5+);
6+--> statement-breakpoint
7+CREATE TABLE "play_to_artists" (
8+ "play_uri" text NOT NULL,
9+ "artist_mbid" uuid NOT NULL,
10+ "artist_name" text,
11+ CONSTRAINT "play_to_artists_play_uri_artist_mbid_pk" PRIMARY KEY("play_uri","artist_mbid")
12+);
13+--> statement-breakpoint
14+CREATE TABLE "plays" (
15+ "uri" text PRIMARY KEY NOT NULL,
16+ "did" text NOT NULL,
17+ "rkey" text NOT NULL,
18+ "cid" text NOT NULL,
19+ "isrc" text,
20+ "duration" integer,
21+ "track_name" text NOT NULL,
22+ "played_time" timestamp with time zone,
23+ "processed_time" timestamp with time zone DEFAULT now(),
24+ "release_mbid" uuid,
25+ "release_name" text,
26+ "recording_mbid" uuid,
27+ "submission_client_agent" text,
28+ "music_service_base_domain" text
29+);
30+--> statement-breakpoint
31+CREATE TABLE "recordings" (
32+ "mbid" uuid PRIMARY KEY NOT NULL,
33+ "name" text NOT NULL,
34+ "play_count" integer DEFAULT 0
35+);
36+--> statement-breakpoint
37+CREATE TABLE "releases" (
38+ "mbid" uuid PRIMARY KEY NOT NULL,
39+ "name" text NOT NULL,
40+ "play_count" integer DEFAULT 0
41+);
42+--> statement-breakpoint
43+ALTER TABLE "play_to_artists" ADD CONSTRAINT "play_to_artists_play_uri_plays_uri_fk" FOREIGN KEY ("play_uri") REFERENCES "public"."plays"("uri") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
44+ALTER TABLE "play_to_artists" ADD CONSTRAINT "play_to_artists_artist_mbid_artists_mbid_fk" FOREIGN KEY ("artist_mbid") REFERENCES "public"."artists"("mbid") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
45+ALTER TABLE "plays" ADD CONSTRAINT "plays_release_mbid_releases_mbid_fk" FOREIGN KEY ("release_mbid") REFERENCES "public"."releases"("mbid") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
46+ALTER TABLE "plays" ADD CONSTRAINT "plays_recording_mbid_recordings_mbid_fk" FOREIGN KEY ("recording_mbid") REFERENCES "public"."recordings"("mbid") ON DELETE no action ON UPDATE no action;
-17
packages/db/.drizzle/0000_same_maelstrom.sql
···1-CREATE TABLE `auth_session` (
2- `key` text PRIMARY KEY NOT NULL,
3- `session` text NOT NULL
4-);
5---> statement-breakpoint
6-CREATE TABLE `auth_state` (
7- `key` text PRIMARY KEY NOT NULL,
8- `state` text NOT NULL
9-);
10---> statement-breakpoint
11-CREATE TABLE `status` (
12- `uri` text PRIMARY KEY NOT NULL,
13- `authorDid` text NOT NULL,
14- `status` text NOT NULL,
15- `createdAt` text NOT NULL,
16- `indexedAt` text NOT NULL
17-);
···00000000000000000
-3
packages/db/.drizzle/0001_fresh_tana_nile.sql
···1-ALTER TABLE `status` RENAME COLUMN "authorDid" TO "author_did";--> statement-breakpoint
2-ALTER TABLE `status` RENAME COLUMN "createdAt" TO "created_at";--> statement-breakpoint
3-ALTER TABLE `status` RENAME COLUMN "indexedAt" TO "indexed_at";
···000
+6
packages/db/.drizzle/0001_swift_maddog.sql
···000000
···1+CREATE MATERIALIZED VIEW "public"."mv_artist_play_counts" AS (select "artists"."mbid", "artists"."name", count("plays"."uri") as "play_count" from "artists" left join "play_to_artists" on "artists"."mbid" = "play_to_artists"."artist_mbid" left join "plays" on "plays"."uri" = "play_to_artists"."play_uri" group by "artists"."mbid", "artists"."name");--> statement-breakpoint
2+CREATE MATERIALIZED VIEW "public"."mv_global_play_count" AS (select count("uri") as "total_plays", count(distinct "did") as "unique_listeners" from "plays");--> statement-breakpoint
3+CREATE MATERIALIZED VIEW "public"."mv_recording_play_counts" AS (select "recordings"."mbid", "recordings"."name", count("plays"."uri") as "play_count" from "recordings" left join "plays" on "plays"."recording_mbid" = "recordings"."mbid" group by "recordings"."mbid", "recordings"."name");--> statement-breakpoint
4+CREATE MATERIALIZED VIEW "public"."mv_release_play_counts" AS (select "releases"."mbid", "releases"."name", count("plays"."uri") as "play_count" from "releases" left join "plays" on "plays"."release_mbid" = "releases"."mbid" group by "releases"."mbid", "releases"."name");--> statement-breakpoint
5+CREATE MATERIALIZED VIEW "public"."mv_top_artists_30days" AS (select "artists"."mbid", "artists"."name", count("plays"."uri") as "play_count" from "artists" inner join "play_to_artists" on "artists"."mbid" = "play_to_artists"."artist_mbid" inner join "plays" on "plays"."uri" = "play_to_artists"."play_uri" where "plays"."played_time" >= NOW() - INTERVAL '30 days' group by "artists"."mbid", "artists"."name" order by count("plays"."uri") DESC);--> statement-breakpoint
6+CREATE MATERIALIZED VIEW "public"."mv_top_releases_30days" AS (select "releases"."mbid", "releases"."name", count("plays"."uri") as "play_count" from "releases" inner join "plays" on "plays"."release_mbid" = "releases"."mbid" where "plays"."played_time" >= NOW() - INTERVAL '30 days' group by "releases"."mbid", "releases"."name" order by count("plays"."uri") DESC);
-1
packages/db/.drizzle/0002_moaning_roulette.sql
···1-ALTER TABLE `auth_session` RENAME TO `atp_session`;
···1-CREATE TABLE `teal_session` (
2- `key` text PRIMARY KEY NOT NULL,
3- `session` text NOT NULL,
4- `provider` text NOT NULL
5-);
6---> statement-breakpoint
7-CREATE TABLE `teal_user` (
8- `did` text PRIMARY KEY NOT NULL,
9- `handle` text NOT NULL,
10- `email` text NOT NULL,
11- `created_at` text NOT NULL
12-);
···000000000000
+15
packages/db/.drizzle/0003_worried_unicorn.sql
···000000000000000
···1+CREATE TABLE "profiles" (
2+ "did" text PRIMARY KEY NOT NULL,
3+ "display_name" text NOT NULL,
4+ "description" text NOT NULL,
5+ "description_facets" jsonb NOT NULL,
6+ "avatar" text NOT NULL,
7+ "banner" text NOT NULL,
8+ "created_at" timestamp NOT NULL
9+);
10+--> statement-breakpoint
11+CREATE TABLE "featured_items" (
12+ "did" text PRIMARY KEY NOT NULL,
13+ "mbid" text NOT NULL,
14+ "type" text NOT NULL
15+);
-29
packages/db/.drizzle/0004_exotic_ironclad.sql
···1-CREATE TABLE `follow` (
2- `follower` text PRIMARY KEY NOT NULL,
3- `followed` text NOT NULL,
4- `created_at` text NOT NULL
5-);
6---> statement-breakpoint
7-CREATE TABLE `play` (
8- `uri` text PRIMARY KEY NOT NULL,
9- `author_did` text NOT NULL,
10- `created_at` text NOT NULL,
11- `indexed_at` text NOT NULL,
12- `track_name` text NOT NULL,
13- `track_mb_id` text,
14- `recording_mb_id` text,
15- `duration` integer,
16- `artist_name` text NOT NULL,
17- `artist_mb_ids` text,
18- `release_name` text,
19- `release_mb_id` text,
20- `isrc` text,
21- `origin_url` text,
22- `music_service_base_domain` text,
23- `submission_client_agent` text,
24- `played_time` text
25-);
26---> statement-breakpoint
27-ALTER TABLE `teal_user` ADD `avatar` text NOT NULL;--> statement-breakpoint
28-ALTER TABLE `teal_user` ADD `bio` text;--> statement-breakpoint
29-ALTER TABLE `teal_user` DROP COLUMN `email`;
···00000000000000000000000000000
+7
packages/db/.drizzle/0004_furry_gravity.sql
···0000000
···1+ALTER TABLE "profiles" ALTER COLUMN "display_name" DROP NOT NULL;--> statement-breakpoint
2+ALTER TABLE "profiles" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
3+ALTER TABLE "profiles" ALTER COLUMN "description_facets" DROP NOT NULL;--> statement-breakpoint
4+ALTER TABLE "profiles" ALTER COLUMN "avatar" DROP NOT NULL;--> statement-breakpoint
5+ALTER TABLE "profiles" ALTER COLUMN "banner" DROP NOT NULL;--> statement-breakpoint
6+ALTER TABLE "profiles" ALTER COLUMN "created_at" DROP NOT NULL;--> statement-breakpoint
7+ALTER TABLE "profiles" ADD COLUMN "handle" text;
···1-import { drizzle } from "drizzle-orm/libsql";
2-import { createClient } from "@libsql/client";
3import * as schema from "./schema";
4import process from "node:process";
5import path from "node:path";
600000007console.log(
8- "Loading SQLite file at",
9- path.join(process.cwd(), "./../../db.sqlite"),
10);
1112-const client = createClient({
13- url:
14- process.env.DATABASE_URL ??
15- "file:" + path.join(process.cwd(), "./../../db.sqlite"),
16-});
1718-export const db = drizzle(client, {
019 schema: schema,
20- casing: "snake_case",
21});
2223// If you need to export the type:
···1+import { drizzle } from "drizzle-orm/postgres-js";
2+import postgres from "postgres";
3import * as schema from "./schema";
4import process from "node:process";
5import path from "node:path";
67+/// Trim a password from a db connection url
8+function withoutPassword(url: string) {
9+ const urlObj = new URL(url);
10+ urlObj.password = "*****";
11+ return urlObj.toString();
12+}
13+14console.log(
15+ "Connecting to database at " +
16+ withoutPassword(process.env.DATABASE_URL ?? ""),
17);
1819+const client = postgres(process.env.DATABASE_URL ?? "");
00002021+export const db = drizzle({
22+ client,
23 schema: schema,
024});
2526// If you need to export the type:
···1+{
2+ "lexicon": 1,
3+ "id": "fm.teal.alpha.actor.searchActors",
4+ "description": "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.",
5+ "defs": {
6+ "main": {
7+ "type": "query",
8+ "parameters": {
9+ "type": "params",
10+ "required": ["q"],
11+ "properties": {
12+ "q": {
13+ "type": "string",
14+ "description": "The search query",
15+ "maxGraphemes": 128,
16+ "maxLength": 640
17+ },
18+ "limit": {
19+ "type": "integer",
20+ "description": "The maximum number of actors to return",
21+ "minimum": 1,
22+ "maximum": 25
23+ },
24+ "cursor": {
25+ "type": "string",
26+ "description": "Cursor for pagination"
27+ }
28+ }
29+ },
30+ "output": {
31+ "encoding": "application/json",
32+ "schema": {
33+ "type": "object",
34+ "required": ["actors"],
35+ "properties": {
36+ "actors": {
37+ "type": "array",
38+ "items": {
39+ "type": "ref",
40+ "ref": "fm.teal.alpha.actor.defs#miniProfileView"
41+ }
42+ },
43+ "cursor": {
44+ "type": "string",
45+ "description": "Cursor for pagination"
46+ }
47+ }
48+ }
49+ }
50+ }
51+ }
52+}
+36
packages/lexicons/src/index.ts
···9 StreamAuthVerifier,
10} from '@atproto/xrpc-server'
11import { schemas } from './lexicons'
00012import * as FmTealAlphaFeedGetActorFeed from './types/fm/teal/alpha/feed/getActorFeed'
13import * as FmTealAlphaFeedGetPlay from './types/fm/teal/alpha/feed/getPlay'
14···105106 constructor(server: Server) {
107 this._server = server
000000000000000000000000000000000108 }
109}
110
···29 originUrl?: string
30 /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided. */
31 musicServiceBaseDomain?: string
32- /** A metadata string specifying the user agent. e.g. com.example.frontend/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if unavailable or not provided. */
33 submissionClientAgent?: string
34 /** The unix timestamp of when the track was played */
35 playedTime?: string
···29 originUrl?: string
30 /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided. */
31 musicServiceBaseDomain?: string
32+ /** A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided. */
33 submissionClientAgent?: string
34 /** The unix timestamp of when the track was played */
35 playedTime?: string